diff --git a/.changelog.yml b/.changelog.yml
index bfdee0c0ca..a7df8779de 100644
--- a/.changelog.yml
+++ b/.changelog.yml
@@ -22,20 +22,25 @@ groups:
     name: FEATURES
     labels:
       - type/feature
-  -
-    name: API
-    labels:
-      - modifies/api
   -
     name: ENHANCEMENTS
     labels:
       - type/enhancement
-      - type/refactoring
-      - topic/ui
+  -
+    name: PERFORMANCE
+    labels:
+      - performance/memory
+      - performance/speed
+      - performance/bigrepo
+      - performance/cpu
   -
     name: BUGFIXES
     labels:
       - type/bug
+  -
+    name: API
+    labels:
+      - modifies/api
   -
     name: TESTING
     labels:
diff --git a/.github/workflows/cron-licenses.yml b/.github/workflows/cron-licenses.yml
index cd8386ecc5..7e57f48aa9 100644
--- a/.github/workflows/cron-licenses.yml
+++ b/.github/workflows/cron-licenses.yml
@@ -1,8 +1,8 @@
 name: cron-licenses
 
 on:
-  schedule:
-    - cron: "7 0 * * 1" # every Monday at 00:07 UTC
+  #schedule:
+  #  - cron: "7 0 * * 1" # every Monday at 00:07 UTC
   workflow_dispatch:
 
 jobs:
diff --git a/MAINTAINERS b/MAINTAINERS
index f0caae4d22..7d21f449fe 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -63,3 +63,4 @@ Kemal Zebari <kemalzebra@gmail.com> (@kemzeb)
 Rowan Bohde <rowan.bohde@gmail.com> (@bohde)
 hiifong <i@hiif.ong> (@hiifong)
 metiftikci <metiftikci@hotmail.com> (@metiftikci)
+Christopher Homberger <christopher.homberger@web.de> (@ChristopherHX)
diff --git a/cmd/admin_user_create.go b/cmd/admin_user_create.go
index bf8cbc7c4c..5e03d6ca3f 100644
--- a/cmd/admin_user_create.go
+++ b/cmd/admin_user_create.go
@@ -31,6 +31,11 @@ var microcmdUserCreate = &cli.Command{
 			Name:  "username",
 			Usage: "Username",
 		},
+		&cli.StringFlag{
+			Name:  "user-type",
+			Usage: "Set user's type: individual or bot",
+			Value: "individual",
+		},
 		&cli.StringFlag{
 			Name:  "password",
 			Usage: "User password",
@@ -77,6 +82,22 @@ func runCreateUser(c *cli.Context) error {
 		return err
 	}
 
+	userTypes := map[string]user_model.UserType{
+		"individual": user_model.UserTypeIndividual,
+		"bot":        user_model.UserTypeBot,
+	}
+	userType, ok := userTypes[c.String("user-type")]
+	if !ok {
+		return fmt.Errorf("invalid user type: %s", c.String("user-type"))
+	}
+	if userType != user_model.UserTypeIndividual {
+		// Some other commands like "change-password" also only support individual users.
+		// It needs to clarify the "password" behavior for bot users in the future.
+		// At the moment, we do not allow setting password for bot users.
+		if c.IsSet("password") || c.IsSet("random-password") {
+			return errors.New("password can only be set for individual users")
+		}
+	}
 	if c.IsSet("name") && c.IsSet("username") {
 		return errors.New("cannot set both --name and --username flags")
 	}
@@ -118,16 +139,19 @@ func runCreateUser(c *cli.Context) error {
 			return err
 		}
 		fmt.Printf("generated random password is '%s'\n", password)
-	} else {
+	} else if userType == user_model.UserTypeIndividual {
 		return errors.New("must set either password or random-password flag")
 	}
 
 	isAdmin := c.Bool("admin")
 	mustChangePassword := true // always default to true
 	if c.IsSet("must-change-password") {
+		if userType != user_model.UserTypeIndividual {
+			return errors.New("must-change-password flag can only be set for individual users")
+		}
 		// if the flag is set, use the value provided by the user
 		mustChangePassword = c.Bool("must-change-password")
-	} else {
+	} else if userType == user_model.UserTypeIndividual {
 		// check whether there are users in the database
 		hasUserRecord, err := db.IsTableNotEmpty(&user_model.User{})
 		if err != nil {
@@ -151,8 +175,9 @@ func runCreateUser(c *cli.Context) error {
 	u := &user_model.User{
 		Name:               username,
 		Email:              c.String("email"),
-		Passwd:             password,
 		IsAdmin:            isAdmin,
+		Type:               userType,
+		Passwd:             password,
 		MustChangePassword: mustChangePassword,
 		Visibility:         visibility,
 	}
diff --git a/cmd/admin_user_create_test.go b/cmd/admin_user_create_test.go
index 83754e97b1..d8044e8de7 100644
--- a/cmd/admin_user_create_test.go
+++ b/cmd/admin_user_create_test.go
@@ -13,32 +13,54 @@ import (
 	user_model "code.gitea.io/gitea/models/user"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestAdminUserCreate(t *testing.T) {
 	app := NewMainApp(AppVersion{})
 
 	reset := func() {
-		assert.NoError(t, db.TruncateBeans(db.DefaultContext, &user_model.User{}))
-		assert.NoError(t, db.TruncateBeans(db.DefaultContext, &user_model.EmailAddress{}))
+		require.NoError(t, db.TruncateBeans(db.DefaultContext, &user_model.User{}))
+		require.NoError(t, db.TruncateBeans(db.DefaultContext, &user_model.EmailAddress{}))
 	}
 
-	type createCheck struct{ IsAdmin, MustChangePassword bool }
-	createUser := func(name, args string) createCheck {
-		assert.NoError(t, app.Run(strings.Fields(fmt.Sprintf("./gitea admin user create --username %s --email %s@gitea.local %s --password foobar", name, name, args))))
-		u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: name})
-		return createCheck{u.IsAdmin, u.MustChangePassword}
-	}
-	reset()
-	assert.Equal(t, createCheck{IsAdmin: false, MustChangePassword: false}, createUser("u", ""), "first non-admin user doesn't need to change password")
+	t.Run("MustChangePassword", func(t *testing.T) {
+		type check struct {
+			IsAdmin            bool
+			MustChangePassword bool
+		}
+		createCheck := func(name, args string) check {
+			require.NoError(t, app.Run(strings.Fields(fmt.Sprintf("./gitea admin user create --username %s --email %s@gitea.local %s --password foobar", name, name, args))))
+			u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: name})
+			return check{IsAdmin: u.IsAdmin, MustChangePassword: u.MustChangePassword}
+		}
+		reset()
+		assert.Equal(t, check{IsAdmin: false, MustChangePassword: false}, createCheck("u", ""), "first non-admin user doesn't need to change password")
 
-	reset()
-	assert.Equal(t, createCheck{IsAdmin: true, MustChangePassword: false}, createUser("u", "--admin"), "first admin user doesn't need to change password")
+		reset()
+		assert.Equal(t, check{IsAdmin: true, MustChangePassword: false}, createCheck("u", "--admin"), "first admin user doesn't need to change password")
 
-	reset()
-	assert.Equal(t, createCheck{IsAdmin: true, MustChangePassword: true}, createUser("u", "--admin --must-change-password"))
-	assert.Equal(t, createCheck{IsAdmin: true, MustChangePassword: true}, createUser("u2", "--admin"))
-	assert.Equal(t, createCheck{IsAdmin: true, MustChangePassword: false}, createUser("u3", "--admin --must-change-password=false"))
-	assert.Equal(t, createCheck{IsAdmin: false, MustChangePassword: true}, createUser("u4", ""))
-	assert.Equal(t, createCheck{IsAdmin: false, MustChangePassword: false}, createUser("u5", "--must-change-password=false"))
+		reset()
+		assert.Equal(t, check{IsAdmin: true, MustChangePassword: true}, createCheck("u", "--admin --must-change-password"))
+		assert.Equal(t, check{IsAdmin: true, MustChangePassword: true}, createCheck("u2", "--admin"))
+		assert.Equal(t, check{IsAdmin: true, MustChangePassword: false}, createCheck("u3", "--admin --must-change-password=false"))
+		assert.Equal(t, check{IsAdmin: false, MustChangePassword: true}, createCheck("u4", ""))
+		assert.Equal(t, check{IsAdmin: false, MustChangePassword: false}, createCheck("u5", "--must-change-password=false"))
+	})
+
+	t.Run("UserType", func(t *testing.T) {
+		createUser := func(name, args string) error {
+			return app.Run(strings.Fields(fmt.Sprintf("./gitea admin user create --username %s --email %s@gitea.local %s", name, name, args)))
+		}
+
+		reset()
+		assert.ErrorContains(t, createUser("u", "--user-type invalid"), "invalid user type")
+		assert.ErrorContains(t, createUser("u", "--user-type bot --password 123"), "can only be set for individual users")
+		assert.ErrorContains(t, createUser("u", "--user-type bot --must-change-password"), "can only be set for individual users")
+
+		assert.NoError(t, createUser("u", "--user-type bot"))
+		u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "u"})
+		assert.Equal(t, user_model.UserTypeBot, u.Type)
+		assert.Equal(t, "", u.Passwd)
+	})
 }
diff --git a/main_timezones.go b/main_timezones.go
new file mode 100644
index 0000000000..e1233007c6
--- /dev/null
+++ b/main_timezones.go
@@ -0,0 +1,16 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build windows
+
+package main
+
+// Golang has the ability to load OS's timezone data from most UNIX systems (https://github.com/golang/go/blob/master/src/time/zoneinfo_unix.go)
+// Even if the timezone data is missing, users could install the related packages to get it.
+// But on Windows, although `zoneinfo_windows.go` tries to load the timezone data from Windows registry,
+// some users still suffer from the issue that the timezone data is missing: https://github.com/go-gitea/gitea/issues/33235
+// So we import the tzdata package to make sure the timezone data is included in the binary.
+//
+// For non-Windows package builders, they could still use the "TAGS=timetzdata" to include the tzdata package in the binary.
+// If we decided to add the tzdata for other platforms, modify the "go:build" directive above.
+import _ "time/tzdata"
diff --git a/models/actions/variable.go b/models/actions/variable.go
index d0f917d923..163bb12c93 100644
--- a/models/actions/variable.go
+++ b/models/actions/variable.go
@@ -58,6 +58,7 @@ func InsertVariable(ctx context.Context, ownerID, repoID int64, name, data strin
 
 type FindVariablesOpts struct {
 	db.ListOptions
+	IDs     []int64
 	RepoID  int64
 	OwnerID int64 // it will be ignored if RepoID is set
 	Name    string
@@ -65,6 +66,15 @@ type FindVariablesOpts struct {
 
 func (opts FindVariablesOpts) ToConds() builder.Cond {
 	cond := builder.NewCond()
+
+	if len(opts.IDs) > 0 {
+		if len(opts.IDs) == 1 {
+			cond = cond.And(builder.Eq{"id": opts.IDs[0]})
+		} else {
+			cond = cond.And(builder.In("id", opts.IDs))
+		}
+	}
+
 	// Since we now support instance-level variables,
 	// there is no need to check for null values for `owner_id` and `repo_id`
 	cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
@@ -85,12 +95,12 @@ func FindVariables(ctx context.Context, opts FindVariablesOpts) ([]*ActionVariab
 	return db.Find[ActionVariable](ctx, opts)
 }
 
-func UpdateVariable(ctx context.Context, variable *ActionVariable) (bool, error) {
-	count, err := db.GetEngine(ctx).ID(variable.ID).Cols("name", "data").
-		Update(&ActionVariable{
-			Name: variable.Name,
-			Data: variable.Data,
-		})
+func UpdateVariableCols(ctx context.Context, variable *ActionVariable, cols ...string) (bool, error) {
+	variable.Name = strings.ToUpper(variable.Name)
+	count, err := db.GetEngine(ctx).
+		ID(variable.ID).
+		Cols(cols...).
+		Update(variable)
 	return count != 0, err
 }
 
diff --git a/models/issues/issue.go b/models/issues/issue.go
index 564a9fb835..5d52f0dd5d 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -17,6 +17,7 @@ import (
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/container"
 	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/optional"
 	"code.gitea.io/gitea/modules/setting"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/timeutil"
@@ -501,6 +502,45 @@ func GetIssueByIndex(ctx context.Context, repoID, index int64) (*Issue, error) {
 	return issue, nil
 }
 
+func isPullToCond(isPull optional.Option[bool]) builder.Cond {
+	if isPull.Has() {
+		return builder.Eq{"is_pull": isPull.Value()}
+	}
+	return builder.NewCond()
+}
+
+func FindLatestUpdatedIssues(ctx context.Context, repoID int64, isPull optional.Option[bool], pageSize int) (IssueList, error) {
+	issues := make([]*Issue, 0, pageSize)
+	err := db.GetEngine(ctx).Where("repo_id = ?", repoID).
+		And(isPullToCond(isPull)).
+		OrderBy("updated_unix DESC").
+		Limit(pageSize).
+		Find(&issues)
+	return issues, err
+}
+
+func FindIssuesSuggestionByKeyword(ctx context.Context, repoID int64, keyword string, isPull optional.Option[bool], excludedID int64, pageSize int) (IssueList, error) {
+	cond := builder.NewCond()
+	if excludedID > 0 {
+		cond = cond.And(builder.Neq{"`id`": excludedID})
+	}
+
+	// It seems that GitHub searches both title and content (maybe sorting by the search engine's ranking system?)
+	// The first PR (https://github.com/go-gitea/gitea/pull/32327) uses "search indexer" to search "name(title) +  content"
+	// But it seems that searching "content" (especially LIKE by DB engine) generates worse (unusable) results.
+	// So now (https://github.com/go-gitea/gitea/pull/33538) it only searches "name(title)", leave the improvements to the future.
+	cond = cond.And(db.BuildCaseInsensitiveLike("`name`", keyword))
+
+	issues := make([]*Issue, 0, pageSize)
+	err := db.GetEngine(ctx).Where("repo_id = ?", repoID).
+		And(isPullToCond(isPull)).
+		And(cond).
+		OrderBy("updated_unix DESC, `index` DESC").
+		Limit(pageSize).
+		Find(&issues)
+	return issues, err
+}
+
 // GetIssueWithAttrsByIndex returns issue by index in a repository.
 func GetIssueWithAttrsByIndex(ctx context.Context, repoID, index int64) (*Issue, error) {
 	issue, err := GetIssueByIndex(ctx, repoID, index)
diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go
index c4515fd898..f520604321 100644
--- a/models/issues/issue_project.go
+++ b/models/issues/issue_project.go
@@ -38,13 +38,15 @@ func (issue *Issue) projectID(ctx context.Context) int64 {
 }
 
 // ProjectColumnID return project column id if issue was assigned to one
-func (issue *Issue) ProjectColumnID(ctx context.Context) int64 {
+func (issue *Issue) ProjectColumnID(ctx context.Context) (int64, error) {
 	var ip project_model.ProjectIssue
 	has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip)
-	if err != nil || !has {
-		return 0
+	if err != nil {
+		return 0, err
+	} else if !has {
+		return 0, nil
 	}
-	return ip.ProjectColumnID
+	return ip.ProjectColumnID, nil
 }
 
 // LoadIssuesFromColumn load issues assigned to this column
diff --git a/models/issues/issue_stats.go b/models/issues/issue_stats.go
index 9ef9347a16..50409fbbd8 100644
--- a/models/issues/issue_stats.go
+++ b/models/issues/issue_stats.go
@@ -107,7 +107,7 @@ func GetIssueStats(ctx context.Context, opts *IssuesOptions) (*IssueStats, error
 		accum.YourRepositoriesCount += stats.YourRepositoriesCount
 		accum.AssignCount += stats.AssignCount
 		accum.CreateCount += stats.CreateCount
-		accum.OpenCount += stats.MentionCount
+		accum.MentionCount += stats.MentionCount
 		accum.ReviewRequestedCount += stats.ReviewRequestedCount
 		accum.ReviewedCount += stats.ReviewedCount
 		i = chunk
diff --git a/models/issues/review.go b/models/issues/review.go
index 3e787273be..1c5c2ee30a 100644
--- a/models/issues/review.go
+++ b/models/issues/review.go
@@ -930,17 +930,19 @@ func MarkConversation(ctx context.Context, comment *Comment, doer *user_model.Us
 }
 
 // CanMarkConversation  Add or remove Conversation mark for a code comment permission check
-// the PR writer , offfcial reviewer and poster can do it
+// the PR writer , official reviewer and poster can do it
 func CanMarkConversation(ctx context.Context, issue *Issue, doer *user_model.User) (permResult bool, err error) {
 	if doer == nil || issue == nil {
 		return false, fmt.Errorf("issue or doer is nil")
 	}
 
+	if err = issue.LoadRepo(ctx); err != nil {
+		return false, err
+	}
+	if issue.Repo.IsArchived {
+		return false, nil
+	}
 	if doer.ID != issue.PosterID {
-		if err = issue.LoadRepo(ctx); err != nil {
-			return false, err
-		}
-
 		p, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
 		if err != nil {
 			return false, err
diff --git a/models/organization/org_worktime.go b/models/organization/org_worktime.go
new file mode 100644
index 0000000000..7b57182a8a
--- /dev/null
+++ b/models/organization/org_worktime.go
@@ -0,0 +1,103 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package organization
+
+import (
+	"sort"
+
+	"code.gitea.io/gitea/models/db"
+
+	"xorm.io/builder"
+)
+
+type WorktimeSumByRepos struct {
+	RepoName string
+	SumTime  int64
+}
+
+func GetWorktimeByRepos(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByRepos, err error) {
+	err = db.GetEngine(db.DefaultContext).
+		Select("repository.name AS repo_name, SUM(tracked_time.time) AS sum_time").
+		Table("tracked_time").
+		Join("INNER", "issue", "tracked_time.issue_id = issue.id").
+		Join("INNER", "repository", "issue.repo_id = repository.id").
+		Where(builder.Eq{"repository.owner_id": org.ID}).
+		And(builder.Eq{"tracked_time.deleted": false}).
+		And(builder.Gte{"tracked_time.created_unix": unitFrom}).
+		And(builder.Lte{"tracked_time.created_unix": unixTo}).
+		GroupBy("repository.name").
+		OrderBy("repository.name").
+		Find(&results)
+	return results, err
+}
+
+type WorktimeSumByMilestones struct {
+	RepoName          string
+	MilestoneName     string
+	MilestoneID       int64
+	MilestoneDeadline int64
+	SumTime           int64
+	HideRepoName      bool
+}
+
+func GetWorktimeByMilestones(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMilestones, err error) {
+	err = db.GetEngine(db.DefaultContext).
+		Select("repository.name AS repo_name, milestone.name AS milestone_name, milestone.id AS milestone_id, milestone.deadline_unix as milestone_deadline, SUM(tracked_time.time) AS sum_time").
+		Table("tracked_time").
+		Join("INNER", "issue", "tracked_time.issue_id = issue.id").
+		Join("INNER", "repository", "issue.repo_id = repository.id").
+		Join("LEFT", "milestone", "issue.milestone_id = milestone.id").
+		Where(builder.Eq{"repository.owner_id": org.ID}).
+		And(builder.Eq{"tracked_time.deleted": false}).
+		And(builder.Gte{"tracked_time.created_unix": unitFrom}).
+		And(builder.Lte{"tracked_time.created_unix": unixTo}).
+		GroupBy("repository.name, milestone.name, milestone.deadline_unix, milestone.id").
+		OrderBy("repository.name, milestone.deadline_unix, milestone.id").
+		Find(&results)
+
+	// TODO: pgsql: NULL values are sorted last in default ascending order, so we need to sort them manually again.
+	sort.Slice(results, func(i, j int) bool {
+		if results[i].RepoName != results[j].RepoName {
+			return results[i].RepoName < results[j].RepoName
+		}
+		if results[i].MilestoneDeadline != results[j].MilestoneDeadline {
+			return results[i].MilestoneDeadline < results[j].MilestoneDeadline
+		}
+		return results[i].MilestoneID < results[j].MilestoneID
+	})
+
+	// Show only the first RepoName, for nicer output.
+	prevRepoName := ""
+	for i := 0; i < len(results); i++ {
+		res := &results[i]
+		res.MilestoneDeadline = 0 // clear the deadline because we do not really need it
+		if prevRepoName == res.RepoName {
+			res.HideRepoName = true
+		}
+		prevRepoName = res.RepoName
+	}
+	return results, err
+}
+
+type WorktimeSumByMembers struct {
+	UserName string
+	SumTime  int64
+}
+
+func GetWorktimeByMembers(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMembers, err error) {
+	err = db.GetEngine(db.DefaultContext).
+		Select("`user`.name AS user_name, SUM(tracked_time.time) AS sum_time").
+		Table("tracked_time").
+		Join("INNER", "issue", "tracked_time.issue_id = issue.id").
+		Join("INNER", "repository", "issue.repo_id = repository.id").
+		Join("INNER", "`user`", "tracked_time.user_id = `user`.id").
+		Where(builder.Eq{"repository.owner_id": org.ID}).
+		And(builder.Eq{"tracked_time.deleted": false}).
+		And(builder.Gte{"tracked_time.created_unix": unitFrom}).
+		And(builder.Lte{"tracked_time.created_unix": unixTo}).
+		GroupBy("`user`.name").
+		OrderBy("sum_time DESC").
+		Find(&results)
+	return results, err
+}
diff --git a/models/project/project.go b/models/project/project.go
index edeb0b4742..20b5df0b6e 100644
--- a/models/project/project.go
+++ b/models/project/project.go
@@ -244,6 +244,10 @@ func GetSearchOrderByBySortType(sortType string) db.SearchOrderBy {
 		return db.SearchOrderByRecentUpdated
 	case "leastupdate":
 		return db.SearchOrderByLeastUpdated
+	case "alphabetically":
+		return "title ASC"
+	case "reversealphabetically":
+		return "title DESC"
 	default:
 		return db.SearchOrderByNewest
 	}
diff --git a/models/system/notice_test.go b/models/system/notice_test.go
index 599b2fb65c..9fc9e6cce1 100644
--- a/models/system/notice_test.go
+++ b/models/system/notice_test.go
@@ -45,8 +45,6 @@ func TestCreateRepositoryNotice(t *testing.T) {
 	unittest.AssertExistsAndLoadBean(t, noticeBean)
 }
 
-// TODO TestRemoveAllWithNotice
-
 func TestCountNotices(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 	assert.Equal(t, int64(3), system.CountNotices(db.DefaultContext))
diff --git a/models/user/user.go b/models/user/user.go
index e13fb6ab3c..293c876957 100644
--- a/models/user/user.go
+++ b/models/user/user.go
@@ -385,11 +385,12 @@ func (u *User) ValidatePassword(passwd string) bool {
 }
 
 // IsPasswordSet checks if the password is set or left empty
+// TODO: It's better to clarify the "password" behavior for different types (individual, bot)
 func (u *User) IsPasswordSet() bool {
-	return len(u.Passwd) != 0
+	return u.Passwd != ""
 }
 
-// IsOrganization returns true if user is actually a organization.
+// IsOrganization returns true if user is actually an organization.
 func (u *User) IsOrganization() bool {
 	return u.Type == UserTypeOrganization
 }
@@ -399,13 +400,14 @@ func (u *User) IsIndividual() bool {
 	return u.Type == UserTypeIndividual
 }
 
-func (u *User) IsUser() bool {
-	return u.Type == UserTypeIndividual || u.Type == UserTypeBot
+// IsTypeBot returns whether the user is of type bot
+func (u *User) IsTypeBot() bool {
+	return u.Type == UserTypeBot
 }
 
-// IsBot returns whether or not the user is of type bot
-func (u *User) IsBot() bool {
-	return u.Type == UserTypeBot
+// IsTokenAccessAllowed returns whether the user is an individual or a bot (which allows for token access)
+func (u *User) IsTokenAccessAllowed() bool {
+	return u.Type == UserTypeIndividual || u.Type == UserTypeBot
 }
 
 // DisplayName returns full name if it's not empty,
diff --git a/models/user/user_system.go b/models/user/user_system.go
index e54973dc8e..6fbfd9e69e 100644
--- a/models/user/user_system.go
+++ b/models/user/user_system.go
@@ -56,7 +56,7 @@ func NewActionsUser() *User {
 		Email:                   ActionsUserEmail,
 		KeepEmailPrivate:        true,
 		LoginName:               ActionsUserName,
-		Type:                    UserTypeIndividual,
+		Type:                    UserTypeBot,
 		AllowCreateOrganization: true,
 		Visibility:              structs.VisibleTypePublic,
 	}
diff --git a/modules/base/tool.go b/modules/base/tool.go
index 1d16186bc5..b6ed8cbf9a 100644
--- a/modules/base/tool.go
+++ b/modules/base/tool.go
@@ -18,7 +18,6 @@ import (
 	"time"
 
 	"code.gitea.io/gitea/modules/git"
-	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/util"
 
@@ -64,10 +63,7 @@ func VerifyTimeLimitCode(now time.Time, data string, minutes int, code string) b
 	// check code
 	retCode := CreateTimeLimitCode(data, aliveTime, startTimeStr, nil)
 	if subtle.ConstantTimeCompare([]byte(retCode), []byte(code)) != 1 {
-		retCode = CreateTimeLimitCode(data, aliveTime, startTimeStr, sha1.New()) // TODO: this is only for the support of legacy codes, remove this in/after 1.23
-		if subtle.ConstantTimeCompare([]byte(retCode), []byte(code)) != 1 {
-			return false
-		}
+		return false
 	}
 
 	// check time is expired or not: startTime <= now && now < startTime + minutes
@@ -144,13 +140,12 @@ func Int64sToStrings(ints []int64) []string {
 	return strs
 }
 
-// EntryIcon returns the octicon class for displaying files/directories
+// EntryIcon returns the octicon name for displaying files/directories
 func EntryIcon(entry *git.TreeEntry) string {
 	switch {
 	case entry.IsLink():
 		te, err := entry.FollowLink()
 		if err != nil {
-			log.Debug(err.Error())
 			return "file-symlink-file"
 		}
 		if te.IsDir() {
diff --git a/modules/base/tool_test.go b/modules/base/tool_test.go
index c821a55c19..7cebedb073 100644
--- a/modules/base/tool_test.go
+++ b/modules/base/tool_test.go
@@ -86,13 +86,10 @@ JWT_SECRET = %s
 		verifyDataCode := func(c string) bool {
 			return VerifyTimeLimitCode(now, "data", 2, c)
 		}
-		code1 := CreateTimeLimitCode("data", 2, now, sha1.New())
-		code2 := CreateTimeLimitCode("data", 2, now, nil)
-		assert.True(t, verifyDataCode(code1))
-		assert.True(t, verifyDataCode(code2))
+		code := CreateTimeLimitCode("data", 2, now, nil)
+		assert.True(t, verifyDataCode(code))
 		initGeneralSecret("000_QLUd4fYVyxetjxC4eZkrBgWM2SndOOWDNtgUUko")
-		assert.False(t, verifyDataCode(code1))
-		assert.False(t, verifyDataCode(code2))
+		assert.False(t, verifyDataCode(code))
 	})
 }
 
@@ -137,5 +134,3 @@ func TestInt64sToStrings(t *testing.T) {
 		Int64sToStrings([]int64{1, 4, 16, 64, 256}),
 	)
 }
-
-// TODO: Test EntryIcon
diff --git a/modules/git/parse.go b/modules/git/parse.go
index eb26632cc0..a7f5c58e89 100644
--- a/modules/git/parse.go
+++ b/modules/git/parse.go
@@ -46,19 +46,9 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) {
 		entry.Size = optional.Some(size)
 	}
 
-	switch string(entryMode) {
-	case "100644":
-		entry.EntryMode = EntryModeBlob
-	case "100755":
-		entry.EntryMode = EntryModeExec
-	case "120000":
-		entry.EntryMode = EntryModeSymlink
-	case "160000":
-		entry.EntryMode = EntryModeCommit
-	case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons
-		entry.EntryMode = EntryModeTree
-	default:
-		return nil, fmt.Errorf("unknown type: %v", string(entryMode))
+	entry.EntryMode, err = ParseEntryMode(string(entryMode))
+	if err != nil || entry.EntryMode == EntryModeNoEntry {
+		return nil, fmt.Errorf("invalid ls-tree output (invalid mode): %q, err: %w", line, err)
 	}
 
 	entry.ID, err = NewIDFromString(string(entryObjectID))
diff --git a/modules/git/tree_entry_mode.go b/modules/git/tree_entry_mode.go
index a399118cf8..ec4487549d 100644
--- a/modules/git/tree_entry_mode.go
+++ b/modules/git/tree_entry_mode.go
@@ -3,7 +3,10 @@
 
 package git
 
-import "strconv"
+import (
+	"fmt"
+	"strconv"
+)
 
 // EntryMode the type of the object in the git tree
 type EntryMode int
@@ -11,6 +14,9 @@ type EntryMode int
 // There are only a few file modes in Git. They look like unix file modes, but they can only be
 // one of these.
 const (
+	// EntryModeNoEntry is possible if the file was added or removed in a commit. In the case of
+	// added the base commit will not have the file in its tree so a mode of 0o000000 is used.
+	EntryModeNoEntry EntryMode = 0o000000
 	// EntryModeBlob
 	EntryModeBlob EntryMode = 0o100644
 	// EntryModeExec
@@ -33,3 +39,22 @@ func ToEntryMode(value string) EntryMode {
 	v, _ := strconv.ParseInt(value, 8, 32)
 	return EntryMode(v)
 }
+
+func ParseEntryMode(mode string) (EntryMode, error) {
+	switch mode {
+	case "000000":
+		return EntryModeNoEntry, nil
+	case "100644":
+		return EntryModeBlob, nil
+	case "100755":
+		return EntryModeExec, nil
+	case "120000":
+		return EntryModeSymlink, nil
+	case "160000":
+		return EntryModeCommit, nil
+	case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons
+		return EntryModeTree, nil
+	default:
+		return 0, fmt.Errorf("unparsable entry mode: %s", mode)
+	}
+}
diff --git a/modules/httplib/request.go b/modules/httplib/request.go
index 880d7ad3cb..267e276df3 100644
--- a/modules/httplib/request.go
+++ b/modules/httplib/request.go
@@ -99,10 +99,10 @@ func (r *Request) Param(key, value string) *Request {
 	return r
 }
 
-// Body adds request raw body.
-// it supports string and []byte.
+// Body adds request raw body. It supports string, []byte and io.Reader as body.
 func (r *Request) Body(data any) *Request {
 	switch t := data.(type) {
+	case nil: // do nothing
 	case string:
 		bf := bytes.NewBufferString(t)
 		r.req.Body = io.NopCloser(bf)
@@ -111,6 +111,12 @@ func (r *Request) Body(data any) *Request {
 		bf := bytes.NewBuffer(t)
 		r.req.Body = io.NopCloser(bf)
 		r.req.ContentLength = int64(len(t))
+	case io.ReadCloser:
+		r.req.Body = t
+	case io.Reader:
+		r.req.Body = io.NopCloser(t)
+	default:
+		panic(fmt.Sprintf("unsupported request body type %T", t))
 	}
 	return r
 }
@@ -141,7 +147,7 @@ func (r *Request) getResponse() (*http.Response, error) {
 		}
 	} else if r.req.Method == "POST" && r.req.Body == nil && len(paramBody) > 0 {
 		r.Header("Content-Type", "application/x-www-form-urlencoded")
-		r.Body(paramBody)
+		r.Body(paramBody) // string
 	}
 
 	var err error
@@ -185,6 +191,7 @@ func (r *Request) getResponse() (*http.Response, error) {
 }
 
 // Response executes request client gets response manually.
+// Caller MUST close the response body if no error occurs
 func (r *Request) Response() (*http.Response, error) {
 	return r.getResponse()
 }
diff --git a/modules/indexer/issues/util.go b/modules/indexer/issues/util.go
index deb19adc49..19d835a1d8 100644
--- a/modules/indexer/issues/util.go
+++ b/modules/indexer/issues/util.go
@@ -92,6 +92,11 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
 		projectID = issue.Project.ID
 	}
 
+	projectColumnID, err := issue.ProjectColumnID(ctx)
+	if err != nil {
+		return nil, false, err
+	}
+
 	return &internal.IndexerData{
 		ID:                 issue.ID,
 		RepoID:             issue.RepoID,
@@ -106,7 +111,7 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
 		NoLabel:            len(labels) == 0,
 		MilestoneID:        issue.MilestoneID,
 		ProjectID:          projectID,
-		ProjectColumnID:    issue.ProjectColumnID(ctx),
+		ProjectColumnID:    projectColumnID,
 		PosterID:           issue.PosterID,
 		AssigneeID:         issue.AssigneeID,
 		MentionIDs:         mentionIDs,
diff --git a/modules/lfs/http_client.go b/modules/lfs/http_client.go
index 3acd23b8f7..0a27fb0c86 100644
--- a/modules/lfs/http_client.go
+++ b/modules/lfs/http_client.go
@@ -72,10 +72,14 @@ func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Poin
 
 	url := fmt.Sprintf("%s/objects/batch", c.endpoint)
 
+	// Original:  In some lfs server implementations, they require the ref attribute. #32838
 	// `ref` is an "optional object describing the server ref that the objects belong to"
-	// but some (incorrect) lfs servers require it, so maybe adding an empty ref here doesn't break the correct ones.
+	// but some (incorrect) lfs servers like aliyun require it, so maybe adding an empty ref here doesn't break the correct ones.
 	// https://github.com/git-lfs/git-lfs/blob/a32a02b44bf8a511aa14f047627c49e1a7fd5021/docs/api/batch.md?plain=1#L37
-	request := &BatchRequest{operation, c.transferNames(), &Reference{}, objects}
+	//
+	// UPDATE: it can't use "empty ref" here because it breaks others like https://github.com/go-gitea/gitea/issues/33453
+	request := &BatchRequest{operation, c.transferNames(), nil, objects}
+
 	payload := new(bytes.Buffer)
 	err := json.NewEncoder(payload).Encode(request)
 	if err != nil {
diff --git a/modules/lfstransfer/backend/backend.go b/modules/lfstransfer/backend/backend.go
index 2b1fe49fda..540932b930 100644
--- a/modules/lfstransfer/backend/backend.go
+++ b/modules/lfstransfer/backend/backend.go
@@ -4,7 +4,6 @@
 package backend
 
 import (
-	"bytes"
 	"context"
 	"encoding/base64"
 	"fmt"
@@ -29,7 +28,7 @@ var Capabilities = []string{
 	"locking",
 }
 
-var _ transfer.Backend = &GiteaBackend{}
+var _ transfer.Backend = (*GiteaBackend)(nil)
 
 // GiteaBackend is an adapter between git-lfs-transfer library and Gitea's internal LFS API
 type GiteaBackend struct {
@@ -78,17 +77,17 @@ func (g *GiteaBackend) Batch(_ string, pointers []transfer.BatchItem, args trans
 		headerAccept:            mimeGitLFS,
 		headerContentType:       mimeGitLFS,
 	}
-	req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes)
+	req := newInternalRequestLFS(g.ctx, url, http.MethodPost, headers, bodyBytes)
 	resp, err := req.Response()
 	if err != nil {
 		g.logger.Log("http request error", err)
 		return nil, err
 	}
+	defer resp.Body.Close()
 	if resp.StatusCode != http.StatusOK {
 		g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode))
 		return nil, statusCodeToErr(resp.StatusCode)
 	}
-	defer resp.Body.Close()
 	respBytes, err := io.ReadAll(resp.Body)
 	if err != nil {
 		g.logger.Log("http read error", err)
@@ -158,8 +157,7 @@ func (g *GiteaBackend) Batch(_ string, pointers []transfer.BatchItem, args trans
 	return pointers, nil
 }
 
-// Download implements transfer.Backend. The returned reader must be closed by the
-// caller.
+// Download implements transfer.Backend. The returned reader must be closed by the caller.
 func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser, int64, error) {
 	idMapStr, exists := args[argID]
 	if !exists {
@@ -187,25 +185,25 @@ func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser,
 		headerGiteaInternalAuth: g.internalAuth,
 		headerAccept:            mimeOctetStream,
 	}
-	req := newInternalRequest(g.ctx, url, http.MethodGet, headers, nil)
+	req := newInternalRequestLFS(g.ctx, url, http.MethodGet, headers, nil)
 	resp, err := req.Response()
 	if err != nil {
-		return nil, 0, err
+		return nil, 0, fmt.Errorf("failed to get response: %w", err)
 	}
+	// no need to close the body here by "defer resp.Body.Close()", see below
 	if resp.StatusCode != http.StatusOK {
 		return nil, 0, statusCodeToErr(resp.StatusCode)
 	}
-	defer resp.Body.Close()
-	respBytes, err := io.ReadAll(resp.Body)
+
+	respSize, err := strconv.ParseInt(resp.Header.Get("X-Gitea-LFS-Content-Length"), 10, 64)
 	if err != nil {
-		return nil, 0, err
+		return nil, 0, fmt.Errorf("failed to parse content length: %w", err)
 	}
-	respSize := int64(len(respBytes))
-	respBuf := io.NopCloser(bytes.NewBuffer(respBytes))
-	return respBuf, respSize, nil
+	// transfer.Backend will check io.Closer interface and close this Body reader
+	return resp.Body, respSize, nil
 }
 
-// StartUpload implements transfer.Backend.
+// Upload implements transfer.Backend.
 func (g *GiteaBackend) Upload(oid string, size int64, r io.Reader, args transfer.Args) error {
 	idMapStr, exists := args[argID]
 	if !exists {
@@ -234,15 +232,14 @@ func (g *GiteaBackend) Upload(oid string, size int64, r io.Reader, args transfer
 		headerContentType:       mimeOctetStream,
 		headerContentLength:     strconv.FormatInt(size, 10),
 	}
-	reqBytes, err := io.ReadAll(r)
-	if err != nil {
-		return err
-	}
-	req := newInternalRequest(g.ctx, url, http.MethodPut, headers, reqBytes)
+
+	req := newInternalRequestLFS(g.ctx, url, http.MethodPut, headers, nil)
+	req.Body(r)
 	resp, err := req.Response()
 	if err != nil {
 		return err
 	}
+	defer resp.Body.Close()
 	if resp.StatusCode != http.StatusOK {
 		return statusCodeToErr(resp.StatusCode)
 	}
@@ -284,11 +281,12 @@ func (g *GiteaBackend) Verify(oid string, size int64, args transfer.Args) (trans
 		headerAccept:            mimeGitLFS,
 		headerContentType:       mimeGitLFS,
 	}
-	req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes)
+	req := newInternalRequestLFS(g.ctx, url, http.MethodPost, headers, bodyBytes)
 	resp, err := req.Response()
 	if err != nil {
 		return transfer.NewStatus(transfer.StatusInternalServerError), err
 	}
+	defer resp.Body.Close()
 	if resp.StatusCode != http.StatusOK {
 		return transfer.NewStatus(uint32(resp.StatusCode), http.StatusText(resp.StatusCode)), statusCodeToErr(resp.StatusCode)
 	}
diff --git a/modules/lfstransfer/backend/lock.go b/modules/lfstransfer/backend/lock.go
index f094cce1db..4b45658611 100644
--- a/modules/lfstransfer/backend/lock.go
+++ b/modules/lfstransfer/backend/lock.go
@@ -50,7 +50,7 @@ func (g *giteaLockBackend) Create(path, refname string) (transfer.Lock, error) {
 		headerAccept:            mimeGitLFS,
 		headerContentType:       mimeGitLFS,
 	}
-	req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes)
+	req := newInternalRequestLFS(g.ctx, url, http.MethodPost, headers, bodyBytes)
 	resp, err := req.Response()
 	if err != nil {
 		g.logger.Log("http request error", err)
@@ -102,7 +102,7 @@ func (g *giteaLockBackend) Unlock(lock transfer.Lock) error {
 		headerAccept:            mimeGitLFS,
 		headerContentType:       mimeGitLFS,
 	}
-	req := newInternalRequest(g.ctx, url, http.MethodPost, headers, bodyBytes)
+	req := newInternalRequestLFS(g.ctx, url, http.MethodPost, headers, bodyBytes)
 	resp, err := req.Response()
 	if err != nil {
 		g.logger.Log("http request error", err)
@@ -185,7 +185,7 @@ func (g *giteaLockBackend) queryLocks(v url.Values) ([]transfer.Lock, string, er
 		headerAccept:            mimeGitLFS,
 		headerContentType:       mimeGitLFS,
 	}
-	req := newInternalRequest(g.ctx, url, http.MethodGet, headers, nil)
+	req := newInternalRequestLFS(g.ctx, url, http.MethodGet, headers, nil)
 	resp, err := req.Response()
 	if err != nil {
 		g.logger.Log("http request error", err)
diff --git a/modules/lfstransfer/backend/util.go b/modules/lfstransfer/backend/util.go
index cffefef375..f322d54257 100644
--- a/modules/lfstransfer/backend/util.go
+++ b/modules/lfstransfer/backend/util.go
@@ -5,15 +5,12 @@ package backend
 
 import (
 	"context"
-	"crypto/tls"
 	"fmt"
-	"net"
+	"io"
 	"net/http"
-	"time"
 
 	"code.gitea.io/gitea/modules/httplib"
-	"code.gitea.io/gitea/modules/proxyprotocol"
-	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/private"
 
 	"github.com/charmbracelet/git-lfs-transfer/transfer"
 )
@@ -89,53 +86,19 @@ func statusCodeToErr(code int) error {
 	}
 }
 
-func newInternalRequest(ctx context.Context, url, method string, headers map[string]string, body []byte) *httplib.Request {
-	req := httplib.NewRequest(url, method).
-		SetContext(ctx).
-		SetTimeout(10*time.Second, 60*time.Second).
-		SetTLSClientConfig(&tls.Config{
-			InsecureSkipVerify: true,
-		})
-
-	if setting.Protocol == setting.HTTPUnix {
-		req.SetTransport(&http.Transport{
-			DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
-				var d net.Dialer
-				conn, err := d.DialContext(ctx, "unix", setting.HTTPAddr)
-				if err != nil {
-					return conn, err
-				}
-				if setting.LocalUseProxyProtocol {
-					if err = proxyprotocol.WriteLocalHeader(conn); err != nil {
-						_ = conn.Close()
-						return nil, err
-					}
-				}
-				return conn, err
-			},
-		})
-	} else if setting.LocalUseProxyProtocol {
-		req.SetTransport(&http.Transport{
-			DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
-				var d net.Dialer
-				conn, err := d.DialContext(ctx, network, address)
-				if err != nil {
-					return conn, err
-				}
-				if err = proxyprotocol.WriteLocalHeader(conn); err != nil {
-					_ = conn.Close()
-					return nil, err
-				}
-				return conn, err
-			},
-		})
-	}
-
+func newInternalRequestLFS(ctx context.Context, url, method string, headers map[string]string, body any) *httplib.Request {
+	req := private.NewInternalRequest(ctx, url, method)
 	for k, v := range headers {
 		req.Header(k, v)
 	}
-
-	req.Body(body)
-
+	switch body := body.(type) {
+	case nil: // do nothing
+	case []byte:
+		req.Body(body) // []byte
+	case io.Reader:
+		req.Body(body) // io.Reader or io.ReadCloser
+	default:
+		panic(fmt.Sprintf("unsupported request body type %T", body))
+	}
 	return req
 }
diff --git a/modules/markup/html.go b/modules/markup/html.go
index bb12febf27..3aaf669c63 100644
--- a/modules/markup/html.go
+++ b/modules/markup/html.go
@@ -47,7 +47,7 @@ var globalVars = sync.OnceValue(func() *globalVarsType {
 	// NOTE: All below regex matching do not perform any extra validation.
 	// Thus a link is produced even if the linked entity does not exist.
 	// While fast, this is also incorrect and lead to false positives.
-	// TODO: fix invalid linking issue
+	// TODO: fix invalid linking issue (update: stale TODO, what issues? maybe no TODO anymore)
 
 	// valid chars in encoded path and parameter: [-+~_%.a-zA-Z0-9/]
 
diff --git a/modules/migration/downloader.go b/modules/migration/downloader.go
index 08dbbc29a9..669222dea2 100644
--- a/modules/migration/downloader.go
+++ b/modules/migration/downloader.go
@@ -12,18 +12,17 @@ import (
 
 // Downloader downloads the site repo information
 type Downloader interface {
-	SetContext(context.Context)
-	GetRepoInfo() (*Repository, error)
-	GetTopics() ([]string, error)
-	GetMilestones() ([]*Milestone, error)
-	GetReleases() ([]*Release, error)
-	GetLabels() ([]*Label, error)
-	GetIssues(page, perPage int) ([]*Issue, bool, error)
-	GetComments(commentable Commentable) ([]*Comment, bool, error)
-	GetAllComments(page, perPage int) ([]*Comment, bool, error)
+	GetRepoInfo(ctx context.Context) (*Repository, error)
+	GetTopics(ctx context.Context) ([]string, error)
+	GetMilestones(ctx context.Context) ([]*Milestone, error)
+	GetReleases(ctx context.Context) ([]*Release, error)
+	GetLabels(ctx context.Context) ([]*Label, error)
+	GetIssues(ctx context.Context, page, perPage int) ([]*Issue, bool, error)
+	GetComments(ctx context.Context, commentable Commentable) ([]*Comment, bool, error)
+	GetAllComments(ctx context.Context, page, perPage int) ([]*Comment, bool, error)
 	SupportGetRepoComments() bool
-	GetPullRequests(page, perPage int) ([]*PullRequest, bool, error)
-	GetReviews(reviewable Reviewable) ([]*Review, error)
+	GetPullRequests(ctx context.Context, page, perPage int) ([]*PullRequest, bool, error)
+	GetReviews(ctx context.Context, reviewable Reviewable) ([]*Review, error)
 	FormatCloneURL(opts MigrateOptions, remoteAddr string) (string, error)
 }
 
diff --git a/modules/migration/null_downloader.go b/modules/migration/null_downloader.go
index e5b69331df..e488f6914f 100644
--- a/modules/migration/null_downloader.go
+++ b/modules/migration/null_downloader.go
@@ -13,56 +13,53 @@ type NullDownloader struct{}
 
 var _ Downloader = &NullDownloader{}
 
-// SetContext set context
-func (n NullDownloader) SetContext(_ context.Context) {}
-
 // GetRepoInfo returns a repository information
-func (n NullDownloader) GetRepoInfo() (*Repository, error) {
+func (n NullDownloader) GetRepoInfo(_ context.Context) (*Repository, error) {
 	return nil, ErrNotSupported{Entity: "RepoInfo"}
 }
 
 // GetTopics return repository topics
-func (n NullDownloader) GetTopics() ([]string, error) {
+func (n NullDownloader) GetTopics(_ context.Context) ([]string, error) {
 	return nil, ErrNotSupported{Entity: "Topics"}
 }
 
 // GetMilestones returns milestones
-func (n NullDownloader) GetMilestones() ([]*Milestone, error) {
+func (n NullDownloader) GetMilestones(_ context.Context) ([]*Milestone, error) {
 	return nil, ErrNotSupported{Entity: "Milestones"}
 }
 
 // GetReleases returns releases
-func (n NullDownloader) GetReleases() ([]*Release, error) {
+func (n NullDownloader) GetReleases(_ context.Context) ([]*Release, error) {
 	return nil, ErrNotSupported{Entity: "Releases"}
 }
 
 // GetLabels returns labels
-func (n NullDownloader) GetLabels() ([]*Label, error) {
+func (n NullDownloader) GetLabels(_ context.Context) ([]*Label, error) {
 	return nil, ErrNotSupported{Entity: "Labels"}
 }
 
 // GetIssues returns issues according start and limit
-func (n NullDownloader) GetIssues(page, perPage int) ([]*Issue, bool, error) {
+func (n NullDownloader) GetIssues(_ context.Context, page, perPage int) ([]*Issue, bool, error) {
 	return nil, false, ErrNotSupported{Entity: "Issues"}
 }
 
 // GetComments returns comments of an issue or PR
-func (n NullDownloader) GetComments(commentable Commentable) ([]*Comment, bool, error) {
+func (n NullDownloader) GetComments(_ context.Context, commentable Commentable) ([]*Comment, bool, error) {
 	return nil, false, ErrNotSupported{Entity: "Comments"}
 }
 
 // GetAllComments returns paginated comments
-func (n NullDownloader) GetAllComments(page, perPage int) ([]*Comment, bool, error) {
+func (n NullDownloader) GetAllComments(_ context.Context, page, perPage int) ([]*Comment, bool, error) {
 	return nil, false, ErrNotSupported{Entity: "AllComments"}
 }
 
 // GetPullRequests returns pull requests according page and perPage
-func (n NullDownloader) GetPullRequests(page, perPage int) ([]*PullRequest, bool, error) {
+func (n NullDownloader) GetPullRequests(_ context.Context, page, perPage int) ([]*PullRequest, bool, error) {
 	return nil, false, ErrNotSupported{Entity: "PullRequests"}
 }
 
 // GetReviews returns pull requests review
-func (n NullDownloader) GetReviews(reviewable Reviewable) ([]*Review, error) {
+func (n NullDownloader) GetReviews(_ context.Context, reviewable Reviewable) ([]*Review, error) {
 	return nil, ErrNotSupported{Entity: "Reviews"}
 }
 
diff --git a/modules/migration/retry_downloader.go b/modules/migration/retry_downloader.go
index 1cacf5f375..2926c40df7 100644
--- a/modules/migration/retry_downloader.go
+++ b/modules/migration/retry_downloader.go
@@ -49,21 +49,15 @@ func (d *RetryDownloader) retry(work func() error) error {
 	return err
 }
 
-// SetContext set context
-func (d *RetryDownloader) SetContext(ctx context.Context) {
-	d.ctx = ctx
-	d.Downloader.SetContext(ctx)
-}
-
 // GetRepoInfo returns a repository information with retry
-func (d *RetryDownloader) GetRepoInfo() (*Repository, error) {
+func (d *RetryDownloader) GetRepoInfo(ctx context.Context) (*Repository, error) {
 	var (
 		repo *Repository
 		err  error
 	)
 
 	err = d.retry(func() error {
-		repo, err = d.Downloader.GetRepoInfo()
+		repo, err = d.Downloader.GetRepoInfo(ctx)
 		return err
 	})
 
@@ -71,14 +65,14 @@ func (d *RetryDownloader) GetRepoInfo() (*Repository, error) {
 }
 
 // GetTopics returns a repository's topics with retry
-func (d *RetryDownloader) GetTopics() ([]string, error) {
+func (d *RetryDownloader) GetTopics(ctx context.Context) ([]string, error) {
 	var (
 		topics []string
 		err    error
 	)
 
 	err = d.retry(func() error {
-		topics, err = d.Downloader.GetTopics()
+		topics, err = d.Downloader.GetTopics(ctx)
 		return err
 	})
 
@@ -86,14 +80,14 @@ func (d *RetryDownloader) GetTopics() ([]string, error) {
 }
 
 // GetMilestones returns a repository's milestones with retry
-func (d *RetryDownloader) GetMilestones() ([]*Milestone, error) {
+func (d *RetryDownloader) GetMilestones(ctx context.Context) ([]*Milestone, error) {
 	var (
 		milestones []*Milestone
 		err        error
 	)
 
 	err = d.retry(func() error {
-		milestones, err = d.Downloader.GetMilestones()
+		milestones, err = d.Downloader.GetMilestones(ctx)
 		return err
 	})
 
@@ -101,14 +95,14 @@ func (d *RetryDownloader) GetMilestones() ([]*Milestone, error) {
 }
 
 // GetReleases returns a repository's releases with retry
-func (d *RetryDownloader) GetReleases() ([]*Release, error) {
+func (d *RetryDownloader) GetReleases(ctx context.Context) ([]*Release, error) {
 	var (
 		releases []*Release
 		err      error
 	)
 
 	err = d.retry(func() error {
-		releases, err = d.Downloader.GetReleases()
+		releases, err = d.Downloader.GetReleases(ctx)
 		return err
 	})
 
@@ -116,14 +110,14 @@ func (d *RetryDownloader) GetReleases() ([]*Release, error) {
 }
 
 // GetLabels returns a repository's labels with retry
-func (d *RetryDownloader) GetLabels() ([]*Label, error) {
+func (d *RetryDownloader) GetLabels(ctx context.Context) ([]*Label, error) {
 	var (
 		labels []*Label
 		err    error
 	)
 
 	err = d.retry(func() error {
-		labels, err = d.Downloader.GetLabels()
+		labels, err = d.Downloader.GetLabels(ctx)
 		return err
 	})
 
@@ -131,7 +125,7 @@ func (d *RetryDownloader) GetLabels() ([]*Label, error) {
 }
 
 // GetIssues returns a repository's issues with retry
-func (d *RetryDownloader) GetIssues(page, perPage int) ([]*Issue, bool, error) {
+func (d *RetryDownloader) GetIssues(ctx context.Context, page, perPage int) ([]*Issue, bool, error) {
 	var (
 		issues []*Issue
 		isEnd  bool
@@ -139,7 +133,7 @@ func (d *RetryDownloader) GetIssues(page, perPage int) ([]*Issue, bool, error) {
 	)
 
 	err = d.retry(func() error {
-		issues, isEnd, err = d.Downloader.GetIssues(page, perPage)
+		issues, isEnd, err = d.Downloader.GetIssues(ctx, page, perPage)
 		return err
 	})
 
@@ -147,7 +141,7 @@ func (d *RetryDownloader) GetIssues(page, perPage int) ([]*Issue, bool, error) {
 }
 
 // GetComments returns a repository's comments with retry
-func (d *RetryDownloader) GetComments(commentable Commentable) ([]*Comment, bool, error) {
+func (d *RetryDownloader) GetComments(ctx context.Context, commentable Commentable) ([]*Comment, bool, error) {
 	var (
 		comments []*Comment
 		isEnd    bool
@@ -155,7 +149,7 @@ func (d *RetryDownloader) GetComments(commentable Commentable) ([]*Comment, bool
 	)
 
 	err = d.retry(func() error {
-		comments, isEnd, err = d.Downloader.GetComments(commentable)
+		comments, isEnd, err = d.Downloader.GetComments(ctx, commentable)
 		return err
 	})
 
@@ -163,7 +157,7 @@ func (d *RetryDownloader) GetComments(commentable Commentable) ([]*Comment, bool
 }
 
 // GetPullRequests returns a repository's pull requests with retry
-func (d *RetryDownloader) GetPullRequests(page, perPage int) ([]*PullRequest, bool, error) {
+func (d *RetryDownloader) GetPullRequests(ctx context.Context, page, perPage int) ([]*PullRequest, bool, error) {
 	var (
 		prs   []*PullRequest
 		err   error
@@ -171,7 +165,7 @@ func (d *RetryDownloader) GetPullRequests(page, perPage int) ([]*PullRequest, bo
 	)
 
 	err = d.retry(func() error {
-		prs, isEnd, err = d.Downloader.GetPullRequests(page, perPage)
+		prs, isEnd, err = d.Downloader.GetPullRequests(ctx, page, perPage)
 		return err
 	})
 
@@ -179,14 +173,13 @@ func (d *RetryDownloader) GetPullRequests(page, perPage int) ([]*PullRequest, bo
 }
 
 // GetReviews returns pull requests reviews
-func (d *RetryDownloader) GetReviews(reviewable Reviewable) ([]*Review, error) {
+func (d *RetryDownloader) GetReviews(ctx context.Context, reviewable Reviewable) ([]*Review, error) {
 	var (
 		reviews []*Review
 		err     error
 	)
-
 	err = d.retry(func() error {
-		reviews, err = d.Downloader.GetReviews(reviewable)
+		reviews, err = d.Downloader.GetReviews(ctx, reviewable)
 		return err
 	})
 
diff --git a/modules/migration/uploader.go b/modules/migration/uploader.go
index ff642aa4fa..65752e248e 100644
--- a/modules/migration/uploader.go
+++ b/modules/migration/uploader.go
@@ -4,20 +4,22 @@
 
 package migration
 
+import "context"
+
 // Uploader uploads all the information of one repository
 type Uploader interface {
 	MaxBatchInsertSize(tp string) int
-	CreateRepo(repo *Repository, opts MigrateOptions) error
-	CreateTopics(topic ...string) error
-	CreateMilestones(milestones ...*Milestone) error
-	CreateReleases(releases ...*Release) error
-	SyncTags() error
-	CreateLabels(labels ...*Label) error
-	CreateIssues(issues ...*Issue) error
-	CreateComments(comments ...*Comment) error
-	CreatePullRequests(prs ...*PullRequest) error
-	CreateReviews(reviews ...*Review) error
+	CreateRepo(ctx context.Context, repo *Repository, opts MigrateOptions) error
+	CreateTopics(ctx context.Context, topic ...string) error
+	CreateMilestones(ctx context.Context, milestones ...*Milestone) error
+	CreateReleases(ctx context.Context, releases ...*Release) error
+	SyncTags(ctx context.Context) error
+	CreateLabels(ctx context.Context, labels ...*Label) error
+	CreateIssues(ctx context.Context, issues ...*Issue) error
+	CreateComments(ctx context.Context, comments ...*Comment) error
+	CreatePullRequests(ctx context.Context, prs ...*PullRequest) error
+	CreateReviews(ctx context.Context, reviews ...*Review) error
 	Rollback() error
-	Finish() error
+	Finish(ctx context.Context) error
 	Close()
 }
diff --git a/modules/private/actions.go b/modules/private/actions.go
index 311a283650..e68f2f85b0 100644
--- a/modules/private/actions.go
+++ b/modules/private/actions.go
@@ -17,7 +17,7 @@ type GenerateTokenRequest struct {
 func GenerateActionsRunnerToken(ctx context.Context, scope string) (*ResponseText, ResponseExtra) {
 	reqURL := setting.LocalURL + "api/internal/actions/generate_actions_runner_token"
 
-	req := newInternalRequest(ctx, reqURL, "POST", GenerateTokenRequest{
+	req := newInternalRequestAPI(ctx, reqURL, "POST", GenerateTokenRequest{
 		Scope: scope,
 	})
 
diff --git a/modules/private/hook.go b/modules/private/hook.go
index 745c200619..87d6549f9c 100644
--- a/modules/private/hook.go
+++ b/modules/private/hook.go
@@ -85,7 +85,7 @@ type HookProcReceiveRefResult struct {
 // HookPreReceive check whether the provided commits are allowed
 func HookPreReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) ResponseExtra {
 	reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/pre-receive/%s/%s", url.PathEscape(ownerName), url.PathEscape(repoName))
-	req := newInternalRequest(ctx, reqURL, "POST", opts)
+	req := newInternalRequestAPI(ctx, reqURL, "POST", opts)
 	req.SetReadWriteTimeout(time.Duration(60+len(opts.OldCommitIDs)) * time.Second)
 	_, extra := requestJSONResp(req, &ResponseText{})
 	return extra
@@ -94,7 +94,7 @@ func HookPreReceive(ctx context.Context, ownerName, repoName string, opts HookOp
 // HookPostReceive updates services and users
 func HookPostReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (*HookPostReceiveResult, ResponseExtra) {
 	reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/post-receive/%s/%s", url.PathEscape(ownerName), url.PathEscape(repoName))
-	req := newInternalRequest(ctx, reqURL, "POST", opts)
+	req := newInternalRequestAPI(ctx, reqURL, "POST", opts)
 	req.SetReadWriteTimeout(time.Duration(60+len(opts.OldCommitIDs)) * time.Second)
 	return requestJSONResp(req, &HookPostReceiveResult{})
 }
@@ -103,7 +103,7 @@ func HookPostReceive(ctx context.Context, ownerName, repoName string, opts HookO
 func HookProcReceive(ctx context.Context, ownerName, repoName string, opts HookOptions) (*HookProcReceiveResult, ResponseExtra) {
 	reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/proc-receive/%s/%s", url.PathEscape(ownerName), url.PathEscape(repoName))
 
-	req := newInternalRequest(ctx, reqURL, "POST", opts)
+	req := newInternalRequestAPI(ctx, reqURL, "POST", opts)
 	req.SetReadWriteTimeout(time.Duration(60+len(opts.OldCommitIDs)) * time.Second)
 	return requestJSONResp(req, &HookProcReceiveResult{})
 }
@@ -115,7 +115,7 @@ func SetDefaultBranch(ctx context.Context, ownerName, repoName, branch string) R
 		url.PathEscape(repoName),
 		url.PathEscape(branch),
 	)
-	req := newInternalRequest(ctx, reqURL, "POST")
+	req := newInternalRequestAPI(ctx, reqURL, "POST")
 	_, extra := requestJSONResp(req, &ResponseText{})
 	return extra
 }
@@ -123,7 +123,7 @@ func SetDefaultBranch(ctx context.Context, ownerName, repoName, branch string) R
 // SSHLog sends ssh error log response
 func SSHLog(ctx context.Context, isErr bool, msg string) error {
 	reqURL := setting.LocalURL + "api/internal/ssh/log"
-	req := newInternalRequest(ctx, reqURL, "POST", &SSHLogOption{IsError: isErr, Message: msg})
+	req := newInternalRequestAPI(ctx, reqURL, "POST", &SSHLogOption{IsError: isErr, Message: msg})
 	_, extra := requestJSONResp(req, &ResponseText{})
 	return extra.Error
 }
diff --git a/modules/private/internal.go b/modules/private/internal.go
index c7e7773524..3bd4eb06b1 100644
--- a/modules/private/internal.go
+++ b/modules/private/internal.go
@@ -34,7 +34,7 @@ func getClientIP() string {
 	return strings.Fields(sshConnEnv)[0]
 }
 
-func newInternalRequest(ctx context.Context, url, method string, body ...any) *httplib.Request {
+func NewInternalRequest(ctx context.Context, url, method string) *httplib.Request {
 	if setting.InternalToken == "" {
 		log.Fatal(`The INTERNAL_TOKEN setting is missing from the configuration file: %q.
 Ensure you are running in the correct environment or set the correct configuration file with -c.`, setting.CustomConf)
@@ -82,13 +82,17 @@ Ensure you are running in the correct environment or set the correct configurati
 			},
 		})
 	}
+	return req
+}
 
+func newInternalRequestAPI(ctx context.Context, url, method string, body ...any) *httplib.Request {
+	req := NewInternalRequest(ctx, url, method)
 	if len(body) == 1 {
 		req.Header("Content-Type", "application/json")
 		jsonBytes, _ := json.Marshal(body[0])
 		req.Body(jsonBytes)
 	} else if len(body) > 1 {
-		log.Fatal("Too many arguments for newInternalRequest")
+		log.Fatal("Too many arguments for newInternalRequestAPI")
 	}
 
 	req.SetTimeout(10*time.Second, 60*time.Second)
diff --git a/modules/private/key.go b/modules/private/key.go
index dcd1714856..114683b343 100644
--- a/modules/private/key.go
+++ b/modules/private/key.go
@@ -14,7 +14,7 @@ import (
 func UpdatePublicKeyInRepo(ctx context.Context, keyID, repoID int64) error {
 	// Ask for running deliver hook and test pull request tasks.
 	reqURL := setting.LocalURL + fmt.Sprintf("api/internal/ssh/%d/update/%d", keyID, repoID)
-	req := newInternalRequest(ctx, reqURL, "POST")
+	req := newInternalRequestAPI(ctx, reqURL, "POST")
 	_, extra := requestJSONResp(req, &ResponseText{})
 	return extra.Error
 }
@@ -24,7 +24,7 @@ func UpdatePublicKeyInRepo(ctx context.Context, keyID, repoID int64) error {
 func AuthorizedPublicKeyByContent(ctx context.Context, content string) (*ResponseText, ResponseExtra) {
 	// Ask for running deliver hook and test pull request tasks.
 	reqURL := setting.LocalURL + "api/internal/ssh/authorized_keys"
-	req := newInternalRequest(ctx, reqURL, "POST")
+	req := newInternalRequestAPI(ctx, reqURL, "POST")
 	req.Param("content", content)
 	return requestJSONResp(req, &ResponseText{})
 }
diff --git a/modules/private/mail.go b/modules/private/mail.go
index 08de5b7e28..3904e37bea 100644
--- a/modules/private/mail.go
+++ b/modules/private/mail.go
@@ -23,7 +23,7 @@ type Email struct {
 func SendEmail(ctx context.Context, subject, message string, to []string) (*ResponseText, ResponseExtra) {
 	reqURL := setting.LocalURL + "api/internal/mail/send"
 
-	req := newInternalRequest(ctx, reqURL, "POST", Email{
+	req := newInternalRequestAPI(ctx, reqURL, "POST", Email{
 		Subject: subject,
 		Message: message,
 		To:      to,
diff --git a/modules/private/manager.go b/modules/private/manager.go
index 6055e553bd..e3d5ad57e0 100644
--- a/modules/private/manager.go
+++ b/modules/private/manager.go
@@ -18,21 +18,21 @@ import (
 // Shutdown calls the internal shutdown function
 func Shutdown(ctx context.Context) ResponseExtra {
 	reqURL := setting.LocalURL + "api/internal/manager/shutdown"
-	req := newInternalRequest(ctx, reqURL, "POST")
+	req := newInternalRequestAPI(ctx, reqURL, "POST")
 	return requestJSONClientMsg(req, "Shutting down")
 }
 
 // Restart calls the internal restart function
 func Restart(ctx context.Context) ResponseExtra {
 	reqURL := setting.LocalURL + "api/internal/manager/restart"
-	req := newInternalRequest(ctx, reqURL, "POST")
+	req := newInternalRequestAPI(ctx, reqURL, "POST")
 	return requestJSONClientMsg(req, "Restarting")
 }
 
 // ReloadTemplates calls the internal reload-templates function
 func ReloadTemplates(ctx context.Context) ResponseExtra {
 	reqURL := setting.LocalURL + "api/internal/manager/reload-templates"
-	req := newInternalRequest(ctx, reqURL, "POST")
+	req := newInternalRequestAPI(ctx, reqURL, "POST")
 	return requestJSONClientMsg(req, "Reloaded")
 }
 
@@ -45,7 +45,7 @@ type FlushOptions struct {
 // FlushQueues calls the internal flush-queues function
 func FlushQueues(ctx context.Context, timeout time.Duration, nonBlocking bool) ResponseExtra {
 	reqURL := setting.LocalURL + "api/internal/manager/flush-queues"
-	req := newInternalRequest(ctx, reqURL, "POST", FlushOptions{Timeout: timeout, NonBlocking: nonBlocking})
+	req := newInternalRequestAPI(ctx, reqURL, "POST", FlushOptions{Timeout: timeout, NonBlocking: nonBlocking})
 	if timeout > 0 {
 		req.SetReadWriteTimeout(timeout + 10*time.Second)
 	}
@@ -55,28 +55,28 @@ func FlushQueues(ctx context.Context, timeout time.Duration, nonBlocking bool) R
 // PauseLogging pauses logging
 func PauseLogging(ctx context.Context) ResponseExtra {
 	reqURL := setting.LocalURL + "api/internal/manager/pause-logging"
-	req := newInternalRequest(ctx, reqURL, "POST")
+	req := newInternalRequestAPI(ctx, reqURL, "POST")
 	return requestJSONClientMsg(req, "Logging Paused")
 }
 
 // ResumeLogging resumes logging
 func ResumeLogging(ctx context.Context) ResponseExtra {
 	reqURL := setting.LocalURL + "api/internal/manager/resume-logging"
-	req := newInternalRequest(ctx, reqURL, "POST")
+	req := newInternalRequestAPI(ctx, reqURL, "POST")
 	return requestJSONClientMsg(req, "Logging Restarted")
 }
 
 // ReleaseReopenLogging releases and reopens logging files
 func ReleaseReopenLogging(ctx context.Context) ResponseExtra {
 	reqURL := setting.LocalURL + "api/internal/manager/release-and-reopen-logging"
-	req := newInternalRequest(ctx, reqURL, "POST")
+	req := newInternalRequestAPI(ctx, reqURL, "POST")
 	return requestJSONClientMsg(req, "Logging Restarted")
 }
 
 // SetLogSQL sets database logging
 func SetLogSQL(ctx context.Context, on bool) ResponseExtra {
 	reqURL := setting.LocalURL + "api/internal/manager/set-log-sql?on=" + strconv.FormatBool(on)
-	req := newInternalRequest(ctx, reqURL, "POST")
+	req := newInternalRequestAPI(ctx, reqURL, "POST")
 	return requestJSONClientMsg(req, "Log SQL setting set")
 }
 
@@ -91,7 +91,7 @@ type LoggerOptions struct {
 // AddLogger adds a logger
 func AddLogger(ctx context.Context, logger, writer, mode string, config map[string]any) ResponseExtra {
 	reqURL := setting.LocalURL + "api/internal/manager/add-logger"
-	req := newInternalRequest(ctx, reqURL, "POST", LoggerOptions{
+	req := newInternalRequestAPI(ctx, reqURL, "POST", LoggerOptions{
 		Logger: logger,
 		Writer: writer,
 		Mode:   mode,
@@ -103,7 +103,7 @@ func AddLogger(ctx context.Context, logger, writer, mode string, config map[stri
 // RemoveLogger removes a logger
 func RemoveLogger(ctx context.Context, logger, writer string) ResponseExtra {
 	reqURL := setting.LocalURL + fmt.Sprintf("api/internal/manager/remove-logger/%s/%s", url.PathEscape(logger), url.PathEscape(writer))
-	req := newInternalRequest(ctx, reqURL, "POST")
+	req := newInternalRequestAPI(ctx, reqURL, "POST")
 	return requestJSONClientMsg(req, "Removed")
 }
 
@@ -111,7 +111,7 @@ func RemoveLogger(ctx context.Context, logger, writer string) ResponseExtra {
 func Processes(ctx context.Context, out io.Writer, flat, noSystem, stacktraces, json bool, cancel string) ResponseExtra {
 	reqURL := setting.LocalURL + fmt.Sprintf("api/internal/manager/processes?flat=%t&no-system=%t&stacktraces=%t&json=%t&cancel-pid=%s", flat, noSystem, stacktraces, json, url.QueryEscape(cancel))
 
-	req := newInternalRequest(ctx, reqURL, "GET")
+	req := newInternalRequestAPI(ctx, reqURL, "GET")
 	callback := func(resp *http.Response, extra *ResponseExtra) {
 		_, extra.Error = io.Copy(out, resp.Body)
 	}
diff --git a/modules/private/restore_repo.go b/modules/private/restore_repo.go
index 496209d3cb..9c3a008142 100644
--- a/modules/private/restore_repo.go
+++ b/modules/private/restore_repo.go
@@ -24,7 +24,7 @@ type RestoreParams struct {
 func RestoreRepo(ctx context.Context, repoDir, ownerName, repoName string, units []string, validation bool) ResponseExtra {
 	reqURL := setting.LocalURL + "api/internal/restore_repo"
 
-	req := newInternalRequest(ctx, reqURL, "POST", RestoreParams{
+	req := newInternalRequestAPI(ctx, reqURL, "POST", RestoreParams{
 		RepoDir:    repoDir,
 		OwnerName:  ownerName,
 		RepoName:   repoName,
diff --git a/modules/private/serv.go b/modules/private/serv.go
index 480a446954..2ccc6c1129 100644
--- a/modules/private/serv.go
+++ b/modules/private/serv.go
@@ -23,7 +23,7 @@ type KeyAndOwner struct {
 // ServNoCommand returns information about the provided key
 func ServNoCommand(ctx context.Context, keyID int64) (*asymkey_model.PublicKey, *user_model.User, error) {
 	reqURL := setting.LocalURL + fmt.Sprintf("api/internal/serv/none/%d", keyID)
-	req := newInternalRequest(ctx, reqURL, "GET")
+	req := newInternalRequestAPI(ctx, reqURL, "GET")
 	keyAndOwner, extra := requestJSONResp(req, &KeyAndOwner{})
 	if extra.HasError() {
 		return nil, nil, extra.Error
@@ -58,6 +58,6 @@ func ServCommand(ctx context.Context, keyID int64, ownerName, repoName string, m
 			reqURL += fmt.Sprintf("&verb=%s", url.QueryEscape(verb))
 		}
 	}
-	req := newInternalRequest(ctx, reqURL, "GET")
+	req := newInternalRequestAPI(ctx, reqURL, "GET")
 	return requestJSONResp(req, &ServCommandResults{})
 }
diff --git a/modules/structs/org.go b/modules/structs/org.go
index c0a545ac1c..f93b3b6493 100644
--- a/modules/structs/org.go
+++ b/modules/structs/org.go
@@ -57,3 +57,12 @@ type EditOrgOption struct {
 	Visibility                string `json:"visibility" binding:"In(,public,limited,private)"`
 	RepoAdminChangeTeamAccess *bool  `json:"repo_admin_change_team_access"`
 }
+
+// RenameOrgOption options when renaming an organization
+type RenameOrgOption struct {
+	// New username for this org. This name cannot be in use yet by any other user.
+	//
+	// required: true
+	// unique: true
+	NewName string `json:"new_name" binding:"Required"`
+}
diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
index b13f344738..e6d11a8acb 100644
--- a/modules/structs/repo_actions.go
+++ b/modules/structs/repo_actions.go
@@ -32,3 +32,36 @@ type ActionTaskResponse struct {
 	Entries    []*ActionTask `json:"workflow_runs"`
 	TotalCount int64         `json:"total_count"`
 }
+
+// CreateActionWorkflowDispatch represents the payload for triggering a workflow dispatch event
+// swagger:model
+type CreateActionWorkflowDispatch struct {
+	// required: true
+	// example: refs/heads/main
+	Ref string `json:"ref" binding:"Required"`
+	// required: false
+	Inputs map[string]string `json:"inputs,omitempty"`
+}
+
+// ActionWorkflow represents a ActionWorkflow
+type ActionWorkflow struct {
+	ID    string `json:"id"`
+	Name  string `json:"name"`
+	Path  string `json:"path"`
+	State string `json:"state"`
+	// swagger:strfmt date-time
+	CreatedAt time.Time `json:"created_at"`
+	// swagger:strfmt date-time
+	UpdatedAt time.Time `json:"updated_at"`
+	URL       string    `json:"url"`
+	HTMLURL   string    `json:"html_url"`
+	BadgeURL  string    `json:"badge_url"`
+	// swagger:strfmt date-time
+	DeletedAt time.Time `json:"deleted_at,omitempty"`
+}
+
+// ActionWorkflowResponse returns a ActionWorkflow
+type ActionWorkflowResponse struct {
+	Workflows  []*ActionWorkflow `json:"workflows"`
+	TotalCount int64             `json:"total_count"`
+}
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index a2cc166de9..c0b0ddc97d 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -69,7 +69,7 @@ func NewFuncMap() template.FuncMap {
 		// time / number / format
 		"FileSize": base.FileSize,
 		"CountFmt": countFmt,
-		"Sec2Time": util.SecToHours,
+		"Sec2Hour": util.SecToHours,
 
 		"TimeEstimateString": timeEstimateString,
 
diff --git a/modules/util/error.go b/modules/util/error.go
index 0f3597147c..07fadf3cab 100644
--- a/modules/util/error.go
+++ b/modules/util/error.go
@@ -36,6 +36,22 @@ func (w SilentWrap) Unwrap() error {
 	return w.Err
 }
 
+type LocaleWrap struct {
+	err    error
+	TrKey  string
+	TrArgs []any
+}
+
+// Error returns the message
+func (w LocaleWrap) Error() string {
+	return w.err.Error()
+}
+
+// Unwrap returns the underlying error
+func (w LocaleWrap) Unwrap() error {
+	return w.err
+}
+
 // NewSilentWrapErrorf returns an error that formats as the given text but unwraps as the provided error
 func NewSilentWrapErrorf(unwrap error, message string, args ...any) error {
 	if len(args) == 0 {
@@ -63,3 +79,16 @@ func NewAlreadyExistErrorf(message string, args ...any) error {
 func NewNotExistErrorf(message string, args ...any) error {
 	return NewSilentWrapErrorf(ErrNotExist, message, args...)
 }
+
+// ErrWrapLocale wraps an err with a translation key and arguments
+func ErrWrapLocale(err error, trKey string, trArgs ...any) error {
+	return LocaleWrap{err: err, TrKey: trKey, TrArgs: trArgs}
+}
+
+func ErrAsLocale(err error) *LocaleWrap {
+	var e LocaleWrap
+	if errors.As(err, &e) {
+		return &e
+	}
+	return nil
+}
diff --git a/modules/util/sec_to_time.go b/modules/util/sec_to_time.go
index 73667d723e..646f33c82a 100644
--- a/modules/util/sec_to_time.go
+++ b/modules/util/sec_to_time.go
@@ -11,16 +11,20 @@ import (
 // SecToHours converts an amount of seconds to a human-readable hours string.
 // This is stable for planning and managing timesheets.
 // Here it only supports hours and minutes, because a work day could contain 6 or 7 or 8 hours.
+// If the duration is less than 1 minute, it will be shown as seconds.
 func SecToHours(durationVal any) string {
-	duration, _ := ToInt64(durationVal)
-	hours := duration / 3600
-	minutes := (duration / 60) % 60
+	seconds, _ := ToInt64(durationVal)
+	hours := seconds / 3600
+	minutes := (seconds / 60) % 60
 
 	formattedTime := ""
 	formattedTime = formatTime(hours, "hour", formattedTime)
 	formattedTime = formatTime(minutes, "minute", formattedTime)
 
 	// The formatTime() function always appends a space at the end. This will be trimmed
+	if formattedTime == "" && seconds > 0 {
+		formattedTime = formatTime(seconds, "second", "")
+	}
 	return strings.TrimRight(formattedTime, " ")
 }
 
diff --git a/modules/util/sec_to_time_test.go b/modules/util/sec_to_time_test.go
index 71a8801d4f..b67926bbcf 100644
--- a/modules/util/sec_to_time_test.go
+++ b/modules/util/sec_to_time_test.go
@@ -22,4 +22,7 @@ func TestSecToHours(t *testing.T) {
 	assert.Equal(t, "156 hours 30 minutes", SecToHours(6*day+12*hour+30*minute+18*second))
 	assert.Equal(t, "98 hours 16 minutes", SecToHours(4*day+2*hour+16*minute+58*second))
 	assert.Equal(t, "672 hours", SecToHours(4*7*day))
+	assert.Equal(t, "1 second", SecToHours(1))
+	assert.Equal(t, "2 seconds", SecToHours(2))
+	assert.Equal(t, "", SecToHours(nil)) // old behavior, empty means no output
 }
diff --git a/modules/web/middleware/binding.go b/modules/web/middleware/binding.go
index 43e1bbc70e..03e188f509 100644
--- a/modules/web/middleware/binding.go
+++ b/modules/web/middleware/binding.go
@@ -78,7 +78,7 @@ func GetInclude(field reflect.StructField) string {
 	return getRuleBody(field, "Include(")
 }
 
-// Validate validate TODO:
+// Validate validate
 func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Locale) binding.Errors {
 	if errs.Len() == 0 {
 		return errs
diff --git a/options/gitignore/Flutter b/options/gitignore/Flutter
new file mode 100644
index 0000000000..39b8814aec
--- /dev/null
+++ b/options/gitignore/Flutter
@@ -0,0 +1,119 @@
+# Miscellaneous
+*.class
+*.lock
+*.log
+*.pyc
+*.swp
+.buildlog/
+.history
+
+
+
+# Flutter repo-specific
+/bin/cache/
+/bin/internal/bootstrap.bat
+/bin/internal/bootstrap.sh
+/bin/mingit/
+/dev/benchmarks/mega_gallery/
+/dev/bots/.recipe_deps
+/dev/bots/android_tools/
+/dev/devicelab/ABresults*.json
+/dev/docs/doc/
+/dev/docs/flutter.docs.zip
+/dev/docs/lib/
+/dev/docs/pubspec.yaml
+/dev/integration_tests/**/xcuserdata
+/dev/integration_tests/**/Pods
+/packages/flutter/coverage/
+version
+analysis_benchmark.json
+
+# packages file containing multi-root paths
+.packages.generated
+
+# Flutter/Dart/Pub related
+**/doc/api/
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+**/generated_plugin_registrant.dart
+.packages
+.pub-preload-cache/
+.pub/
+build/
+flutter_*.png
+linked_*.ds
+unlinked.ds
+unlinked_spec.ds
+
+# Android related
+**/android/**/gradle-wrapper.jar
+.gradle/
+**/android/captures/
+**/android/gradlew
+**/android/gradlew.bat
+**/android/local.properties
+**/android/**/GeneratedPluginRegistrant.java
+**/android/key.properties
+*.jks
+
+# iOS/XCode related
+**/ios/**/*.mode1v3
+**/ios/**/*.mode2v3
+**/ios/**/*.moved-aside
+**/ios/**/*.pbxuser
+**/ios/**/*.perspectivev3
+**/ios/**/*sync/
+**/ios/**/.sconsign.dblite
+**/ios/**/.tags*
+**/ios/**/.vagrant/
+**/ios/**/DerivedData/
+**/ios/**/Icon?
+**/ios/**/Pods/
+**/ios/**/.symlinks/
+**/ios/**/profile
+**/ios/**/xcuserdata
+**/ios/.generated/
+**/ios/Flutter/.last_build_id
+**/ios/Flutter/App.framework
+**/ios/Flutter/Flutter.framework
+**/ios/Flutter/Flutter.podspec
+**/ios/Flutter/Generated.xcconfig
+**/ios/Flutter/ephemeral
+**/ios/Flutter/app.flx
+**/ios/Flutter/app.zip
+**/ios/Flutter/flutter_assets/
+**/ios/Flutter/flutter_export_environment.sh
+**/ios/ServiceDefinitions.json
+**/ios/Runner/GeneratedPluginRegistrant.*
+
+# macOS
+**/Flutter/ephemeral/
+**/Pods/
+**/macos/Flutter/GeneratedPluginRegistrant.swift
+**/macos/Flutter/ephemeral
+**/xcuserdata/
+
+# Windows
+**/windows/flutter/generated_plugin_registrant.cc
+**/windows/flutter/generated_plugin_registrant.h
+**/windows/flutter/generated_plugins.cmake
+
+# Linux
+**/linux/flutter/generated_plugin_registrant.cc
+**/linux/flutter/generated_plugin_registrant.h
+**/linux/flutter/generated_plugins.cmake
+
+# Coverage
+coverage/
+
+# Symbols
+app.*.symbols
+
+# Exceptions to above rules.
+!**/ios/**/default.mode1v3
+!**/ios/**/default.mode2v3
+!**/ios/**/default.pbxuser
+!**/ios/**/default.perspectivev3
+!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
+!/dev/ci/**/Gemfile.lock
\ No newline at end of file
diff --git a/options/gitignore/Nix b/options/gitignore/Nix
index 1fd04ef1f6..912e6700f4 100644
--- a/options/gitignore/Nix
+++ b/options/gitignore/Nix
@@ -1,3 +1,6 @@
 # Ignore build outputs from performing a nix-build or `nix build` command
 result
 result-*
+
+# Ignore automatically generated direnv output
+.direnv
diff --git a/options/gitignore/NotesAndCoreConfiguration b/options/gitignore/NotesAndCoreConfiguration
new file mode 100644
index 0000000000..4eff01dae1
--- /dev/null
+++ b/options/gitignore/NotesAndCoreConfiguration
@@ -0,0 +1,16 @@
+# Excludes Obsidian workspace cache and plugins. All notes and core obsidian
+# configuration files are tracked by Git.
+
+# The current application UI state (DOM layout, recently-opened files, etc.) is
+# stored in these files (separate for desktop and mobile) so you can resume
+# your session seamlessly after a restart. If you want to track UI state, use
+# the Workspaces core plugin instead of relying on these files.
+.obsidian/workspace.json
+.obsidian/workspace-mobile.json
+
+# Obsidian plugins are stored under .obsidian/plugins/$plugin_name. They
+# contain metadata (manifest.json), application code (main.js), stylesheets
+# (styles.css), and user-configuration data (data.json).
+# We want to exclude all plugin-related files, so we can exclude everything
+# under this directory.
+.obsidian/plugins/**/*
diff --git a/options/gitignore/NotesAndExtendedConfiguration b/options/gitignore/NotesAndExtendedConfiguration
new file mode 100644
index 0000000000..3e0804f299
--- /dev/null
+++ b/options/gitignore/NotesAndExtendedConfiguration
@@ -0,0 +1,38 @@
+# Excludes Obsidian workspace cache and plugin code, but retains plugin
+# configuration. All notes and user-controlled configuration files are tracked
+# by Git.
+#
+# 				!!! WARNING !!!
+#
+# Community plugins may store sensitive secrets in their data.json files. By
+# including these files, those secrets may be tracked in your Git repository.
+#
+# To ignore configurations for specific plugins, add a line like this after the
+# contents of this file (order is important):
+#     .obsidian/plugins/{{plugin_name}}/data.json
+#
+# Alternatively, ensure that you are treating your entire Git repository as
+# sensitive data, since it may contain secrets, or may have contained them in
+# past commits.  Understand your threat profile, and make the decision
+# appropriate for yourself. If in doubt, err on the side of not including
+# plugin configuration. Use one of the alternative gitignore files instead:
+# * NotesOnly.gitignore
+# * NotesAndCoreConfiguration.gitignore
+
+# The current application UI state (DOM layout, recently-opened files, etc.) is
+# stored in these files (separate for desktop and mobile) so you can resume
+# your session seamlessly after a restart. If you want to track UI state, use
+# the Workspaces core plugin instead of relying on these files.
+.obsidian/workspace.json
+.obsidian/workspace-mobile.json
+
+# Obsidian plugins are stored under .obsidian/plugins/$plugin_name. They
+# contain metadata (manifest.json), application code (main.js), stylesheets
+# (styles.css), and user-configuration data (data.json).
+# We only want to track data.json, so we:
+# 1. exclude everything under the plugins directory recursively,
+# 2. unignore the plugin directories themselves, which then allows us to
+# 3. unignore the data.json files
+.obsidian/plugins/**/*
+!.obsidian/plugins/*/
+!.obsidian/plugins/*/data.json
diff --git a/options/gitignore/NotesOnly b/options/gitignore/NotesOnly
new file mode 100644
index 0000000000..2b3b76ee0e
--- /dev/null
+++ b/options/gitignore/NotesOnly
@@ -0,0 +1,4 @@
+# Excludes all Obsidian-related configuration. All notes are tracked by Git.
+
+# All Obsidian configuration and runtime state is stored here
+.obsidian/**/*
diff --git a/options/locale/locale_cs-CZ.ini b/options/locale/locale_cs-CZ.ini
index 91605a1f31..8d43077f50 100644
--- a/options/locale/locale_cs-CZ.ini
+++ b/options/locale/locale_cs-CZ.ini
@@ -54,6 +54,7 @@ webauthn_reload=Znovu načíst
 repository=Repozitář
 organization=Organizace
 mirror=Zrcadlo
+issue_milestone=Milník
 new_repo=Nový repozitář
 new_migrate=Nová migrace
 new_mirror=Nové zrcadlo
@@ -1253,6 +1254,7 @@ labels=Štítky
 org_labels_desc=Štítky na úrovni organizace, které mohou být použity se <strong>všemi repozitáři</strong> v rámci této organizace
 org_labels_desc_manage=spravovat
 
+milestone=Milník
 milestones=Milníky
 commits=Commity
 commit=Commit
@@ -2873,6 +2875,7 @@ view_as_role=Zobrazit jako: %s
 view_as_public_hint=Prohlížíte README jako veřejný uživatel.
 view_as_member_hint=Prohlížíte README jako člen této organizace.
 
+
 [admin]
 maintenance=Údržba
 dashboard=Přehled
diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini
index 29ef51bfc4..32f3a4bbcc 100644
--- a/options/locale/locale_de-DE.ini
+++ b/options/locale/locale_de-DE.ini
@@ -54,6 +54,7 @@ webauthn_reload=Neu laden
 repository=Repository
 organization=Organisation
 mirror=Mirror
+issue_milestone=Meilenstein
 new_repo=Neues Repository
 new_migrate=Neue Migration
 new_mirror=Neuer Mirror
@@ -1247,6 +1248,7 @@ labels=Label
 org_labels_desc=Labels der Organisationsebene, die mit <strong>allen Repositories</strong> in dieser Organisation verwendet werden können
 org_labels_desc_manage=verwalten
 
+milestone=Meilenstein
 milestones=Meilensteine
 commits=Commits
 commit=Commit
@@ -2854,6 +2856,7 @@ teams.invite.by=Von %s eingeladen
 teams.invite.description=Bitte klicke auf die folgende Schaltfläche, um dem Team beizutreten.
 
 
+
 [admin]
 maintenance=Wartung
 dashboard=Dashboard
diff --git a/options/locale/locale_el-GR.ini b/options/locale/locale_el-GR.ini
index e989819c5e..cab6320016 100644
--- a/options/locale/locale_el-GR.ini
+++ b/options/locale/locale_el-GR.ini
@@ -53,6 +53,7 @@ webauthn_reload=Ανανέωση
 repository=Αποθετήριο
 organization=Οργανισμός
 mirror=Αντίγραφο
+issue_milestone=Ορόσημο
 new_repo=Νέο Αποθετήριο
 new_migrate=Νέα Μεταφορά
 new_mirror=Νέο Είδωλο
@@ -1119,6 +1120,7 @@ labels=Σήματα
 org_labels_desc=Τα σήματα στο επίπεδο οργανισμού, που μπορούν να χρησιμοποιηθούν με <strong>όλα τα αποθετήρια</strong> κάτω από αυτόν τον οργανισμό
 org_labels_desc_manage=διαχείριση
 
+milestone=Ορόσημο
 milestones=Ορόσημα
 commits=Υποβολές
 commit=Υποβολή
@@ -2590,6 +2592,7 @@ teams.invite.by=Προσκλήθηκε από %s
 teams.invite.description=Παρακαλώ κάντε κλικ στον παρακάτω σύνδεσμο για συμμετοχή στην ομάδα.
 
 
+
 [admin]
 dashboard=Πίνακας Ελέγχου
 identity_access=Ταυτότητα & Πρόσβαση
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 68b7fa2f9f..bce64a81a3 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -54,6 +54,7 @@ webauthn_reload = Reload
 repository = Repository
 organization = Organization
 mirror = Mirror
+issue_milestone = Milestone
 new_repo = New Repository
 new_migrate = New Migration
 new_mirror = New Mirror
@@ -384,6 +385,13 @@ show_only_public = Showing only public
 
 issues.in_your_repos = In your repositories
 
+guide_title = No Activity
+guide_desc = You are currently not following any repositories or users, so there is no content to display. You can explore repositories or users of interest from the links below.
+explore_repos = Explore repositories
+explore_users = Explore users
+empty_org = There are no organizations yet.
+empty_repo = There are no repositories yet.
+
 [explore]
 repos = Repositories
 users = Users
@@ -1253,6 +1261,7 @@ labels = Labels
 org_labels_desc = Organization level labels that can be used with <strong>all repositories</strong> under this organization
 org_labels_desc_manage = manage
 
+milestone = Milestone
 milestones = Milestones
 commits = Commits
 commit = Commit
@@ -2329,6 +2338,8 @@ settings.event_fork = Fork
 settings.event_fork_desc = Repository forked.
 settings.event_wiki = Wiki
 settings.event_wiki_desc = Wiki page created, renamed, edited or deleted.
+settings.event_statuses = Statuses
+settings.event_statuses_desc = Commit Status updated from the API.
 settings.event_release = Release
 settings.event_release_desc = Release published, updated or deleted in a repository.
 settings.event_push = Push
@@ -2876,6 +2887,15 @@ view_as_role = View as: %s
 view_as_public_hint = You are viewing the README as a public user.
 view_as_member_hint = You are viewing the README as a member of this organization.
 
+worktime = Worktime
+worktime.date_range_start = Start date
+worktime.date_range_end = End date
+worktime.query = Query
+worktime.time = Time
+worktime.by_repositories = By repositories
+worktime.by_milestones = By milestones
+worktime.by_members = By members
+
 [admin]
 maintenance = Maintenance
 dashboard = Dashboard
diff --git a/options/locale/locale_es-ES.ini b/options/locale/locale_es-ES.ini
index 049fb9196d..359f490356 100644
--- a/options/locale/locale_es-ES.ini
+++ b/options/locale/locale_es-ES.ini
@@ -52,6 +52,7 @@ webauthn_reload=Recargar
 repository=Repositorio
 organization=Organización
 mirror=Réplica
+issue_milestone=Hito
 new_repo=Nuevo repositorio
 new_migrate=Nueva migración
 new_mirror=Nueva réplica
@@ -1109,6 +1110,7 @@ labels=Etiquetas
 org_labels_desc=Etiquetas de nivel de la organización que pueden ser utilizadas con <strong>todos los repositorios</strong> bajo esta organización
 org_labels_desc_manage=gestionar
 
+milestone=Hito
 milestones=Hitos
 commits=Commits
 commit=Commit
@@ -2571,6 +2573,7 @@ teams.invite.by=Invitado por %s
 teams.invite.description=Por favor, haga clic en el botón de abajo para unirse al equipo.
 
 
+
 [admin]
 dashboard=Panel de control
 identity_access=Identidad y acceso
diff --git a/options/locale/locale_fa-IR.ini b/options/locale/locale_fa-IR.ini
index 4d90cf9876..a84fcd9bd7 100644
--- a/options/locale/locale_fa-IR.ini
+++ b/options/locale/locale_fa-IR.ini
@@ -1993,6 +1993,7 @@ teams.all_repositories_write_permission_desc=این تیم دسترسی<strong>
 teams.all_repositories_admin_permission_desc=این تیم دسترسی<strong> مدیر </strong> به <strong> مخازن همه</strong> را می بخشد: اعضا می توانند مخازن را بخواند، همکار و مخزن اضافه کنند.
 
 
+
 [admin]
 dashboard=پیشخوان
 users=حساب کاربران
diff --git a/options/locale/locale_fi-FI.ini b/options/locale/locale_fi-FI.ini
index b5fa5c8afc..1c78f049f9 100644
--- a/options/locale/locale_fi-FI.ini
+++ b/options/locale/locale_fi-FI.ini
@@ -49,6 +49,7 @@ webauthn_reload=Päivitä
 repository=Repo
 organization=Organisaatio
 mirror=Peili
+issue_milestone=Merkkipaalu
 new_repo=Uusi repo
 new_migrate=Uusi migraatio
 new_mirror=Uusi peilaus
@@ -720,6 +721,7 @@ projects=Projektit
 packages=Paketit
 labels=Tunnisteet
 
+milestone=Merkkipaalu
 milestones=Merkkipaalut
 commits=Commitit
 commit=Commit
@@ -1361,6 +1363,7 @@ teams.members.none=Ei jäseniä tässä tiimissä.
 teams.all_repositories=Kaikki repot
 
 
+
 [admin]
 dashboard=Kojelauta
 users=Käyttäjätilit
diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini
index a5558eebb0..45cf43956a 100644
--- a/options/locale/locale_fr-FR.ini
+++ b/options/locale/locale_fr-FR.ini
@@ -54,6 +54,7 @@ webauthn_reload=Recharger
 repository=Dépôt
 organization=Organisation
 mirror=Miroir
+issue_milestone=Jalon
 new_repo=Nouveau dépôt
 new_migrate=Nouvelle migration
 new_mirror=Nouveau miroir
@@ -1172,7 +1173,7 @@ migrate_items_releases=Publications
 migrate_repo=Migrer le dépôt
 migrate.clone_address=Migrer/Cloner depuis une URL
 migrate.clone_address_desc=L'URL HTTP(S) ou Git "clone" d'un dépôt existant
-migrate.github_token_desc=Vous pouvez mettre un ou plusieurs jetons séparés par des virgules ici pour rendre la migration plus rapide en raison de la limite de débit de l'API GitHub. ATTENTION : Abuser de cette fonctionnalité peut enfreindre la politique du fournisseur de services et entraîner un blocage de compte.
+migrate.github_token_desc=Vous pouvez mettre un ou plusieurs jetons séparés par des virgules ici pour rendre la migration plus rapide et contourner la limite de débit de l’API GitHub. ATTENTION : Abuser de cette fonctionnalité peut enfreindre la politique du fournisseur de service et entraîner un blocage de votre compte.
 migrate.clone_local_path=ou un chemin serveur local
 migrate.permission_denied=Vous n'êtes pas autorisé à importer des dépôts locaux.
 migrate.permission_denied_blocked=Vous ne pouvez pas importer depuis des hôtes interdits, veuillez demander à l'administrateur de vérifier les paramètres ALLOWED_DOMAINS/ALLOW_LOCALNETWORKS/BLOCKED_DOMAINS.
@@ -1253,6 +1254,7 @@ labels=Labels
 org_labels_desc=Les labels d'une organisation peuvent être utilisés avec <strong>tous les dépôts</strong> de cette organisation.
 org_labels_desc_manage=gérer
 
+milestone=Jalon
 milestones=Jalons
 commits=Révisions
 commit=Révision
@@ -1345,6 +1347,8 @@ editor.new_branch_name_desc=Nouveau nom de la branche…
 editor.cancel=Annuler
 editor.filename_cannot_be_empty=Le nom de fichier ne peut être vide.
 editor.filename_is_invalid=Le nom du fichier est invalide : "%s".
+editor.commit_email=Courriel de la révision
+editor.invalid_commit_email=Le courriel pour la révision n’est pas valide.
 editor.branch_does_not_exist=La branche "%s" n'existe pas dans ce dépôt.
 editor.branch_already_exists=La branche "%s" existe déjà dans ce dépôt.
 editor.directory_is_a_file=Le nom de dossier "%s" est déjà utilisé comme nom de fichier dans ce dépôt.
@@ -1562,12 +1566,12 @@ issues.action_assignee=Assigné à
 issues.action_assignee_no_select=Pas d'assignataire
 issues.action_check=Cocher/Décocher
 issues.action_check_all=Cocher/Décocher tous les éléments
-issues.opened_by=créé %[1]s par <a href="%[2]s">%[3]s</a>
-pulls.merged_by=par <a href="%[2]s">%[3]s</a> fusionné %[1]s.
-pulls.merged_by_fake=par %[2]s fusionné %[1]s.
-issues.closed_by=de <a href="%[2]s">%[3]s</a>, clôt %[1]s
-issues.opened_by_fake=%[1]s ouvert par %[2]s
-issues.closed_by_fake=de %[2]s, clôt %[1]s
+issues.opened_by=ouvert(e) par <a href="%[2]s">%[3]s</a> %[1]s
+pulls.merged_by=par <a href="%[2]s">%[3]s</a> a été fusionnée %[1]s
+pulls.merged_by_fake=par %[2]s a été fusionnée %[1]s
+issues.closed_by=par <a href="%[2]s">%[3]s</a> a été fermé(e) %[1]s
+issues.opened_by_fake=ouvert(e) par %[2]s %[1]s
+issues.closed_by_fake=par %[2]s a été fermé(e) %[1]s
 issues.previous=Précédent
 issues.next=Suivant
 issues.open_title=Ouvert
@@ -1735,8 +1739,8 @@ issues.dependency.added_dependency=`a créé une dépendance %s.`
 issues.dependency.removed_dependency=`a supprimé une dépendance %s.`
 issues.dependency.pr_closing_blockedby=La fermeture de cette demande d’ajout est bloquée par les tickets suivants
 issues.dependency.issue_closing_blockedby=La fermeture de ce ticket est bloquée par les tickets suivants
-issues.dependency.issue_close_blocks=Cette demande d'ajout empêche la clôture des tickets suivants
-issues.dependency.pr_close_blocks=Cette demande d'ajout empêche la clôture des tickets suivants
+issues.dependency.issue_close_blocks=Ce ticket empêche la clôture des tickets suivants
+issues.dependency.pr_close_blocks=Cette demande d’ajout empêche la clôture des tickets suivants
 issues.dependency.issue_close_blocked=Vous devez fermer tous les tickets qui bloquent ce ticket avant de pouvoir le fermer.
 issues.dependency.issue_batch_close_blocked=Impossible de fermer tous les tickets que vous avez choisis, car le ticket #%d a toujours des dépendances ouvertes.
 issues.dependency.pr_close_blocked=Vous devez fermer tous les tickets qui bloquent cette demande d'ajout avant de pouvoir la fusionner.
@@ -2873,6 +2877,7 @@ view_as_role=Voir en tant que %s
 view_as_public_hint=Vous visualisez le README en tant qu’utilisateur public.
 view_as_member_hint=Vous visualisez le README en tant que membre de cette organisation.
 
+
 [admin]
 maintenance=Maintenance
 dashboard=Tableau de bord
diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini
index 805deb618d..b92714b599 100644
--- a/options/locale/locale_ga-IE.ini
+++ b/options/locale/locale_ga-IE.ini
@@ -54,6 +54,7 @@ webauthn_reload=Athlódáil
 repository=Stór
 organization=Eagraíocht
 mirror=Scáthán
+issue_milestone=Cloch Mhíle
 new_repo=Stór Nua
 new_migrate=Imirce Nua
 new_mirror=Scáthán Nua
@@ -1253,6 +1254,7 @@ labels=Lipéid
 org_labels_desc=Lipéid ar leibhéal eagraíochta is féidir a úsáid le <strong>gach stóras</strong> faoin eagraíocht seo
 org_labels_desc_manage=bainistigh
 
+milestone=Cloch Mhíle
 milestones=Clocha míle
 commits=Tiomáintí
 commit=Tiomantas
@@ -1345,6 +1347,8 @@ editor.new_branch_name_desc=Ainm brainse nua…
 editor.cancel=Cealaigh
 editor.filename_cannot_be_empty=Ní féidir ainm an chomhaid a bheith folamh.
 editor.filename_is_invalid=Tá ainm an chomhaid neamhbhailí: "%s".
+editor.commit_email=Tiomantas ríomhphost
+editor.invalid_commit_email=Tá an ríomhphost don ghealltanas neamhbhailí.
 editor.branch_does_not_exist=Níl brainse "%s" ann sa stóras seo.
 editor.branch_already_exists=Tá brainse "%s" ann cheana féin sa stóras seo.
 editor.directory_is_a_file=Úsáidtear ainm eolaire "%s" cheana féin mar ainm comhaid sa stóras seo.
@@ -2326,6 +2330,8 @@ settings.event_fork=Forc
 settings.event_fork_desc=Forcadh stóras.
 settings.event_wiki=Vicí
 settings.event_wiki_desc=Leathanach Vicí cruthaithe, athainmnithe, curtha in eagar nó scriosta.
+settings.event_statuses=Stádais
+settings.event_statuses_desc=Nuashonraíodh Stádas Commit ón API.
 settings.event_release=Scaoileadh
 settings.event_release_desc=Scaoileadh foilsithe, nuashonraithe nó scriosta i stóras.
 settings.event_push=Brúigh
@@ -2873,6 +2879,15 @@ view_as_role=Féach mar: %s
 view_as_public_hint=Tá tú ag féachaint ar an README mar úsáideoir poiblí.
 view_as_member_hint=Tá tú ag féachaint ar an README mar bhall den eagraíocht seo.
 
+worktime=Am oibre
+worktime.date_range_start=Dáta tosaithe
+worktime.date_range_end=Dáta deiridh
+worktime.query=Ceist
+worktime.time=Am
+worktime.by_repositories=De réir stórtha
+worktime.by_milestones=De réir clocha míle
+worktime.by_members=Ag baill
+
 [admin]
 maintenance=Cothabháil
 dashboard=Deais
diff --git a/options/locale/locale_hu-HU.ini b/options/locale/locale_hu-HU.ini
index f0935a2916..579a030567 100644
--- a/options/locale/locale_hu-HU.ini
+++ b/options/locale/locale_hu-HU.ini
@@ -1229,6 +1229,7 @@ teams.specific_repositories=Meghatározott tárolók
 teams.all_repositories=Minden tároló
 
 
+
 [admin]
 dashboard=Műszerfal
 users=Felhasználói fiókok
diff --git a/options/locale/locale_id-ID.ini b/options/locale/locale_id-ID.ini
index 391691ebf5..1fb1efbd16 100644
--- a/options/locale/locale_id-ID.ini
+++ b/options/locale/locale_id-ID.ini
@@ -1084,6 +1084,7 @@ teams.delete_team_success=Tim sudah di hapus.
 teams.repositories=Tim repositori
 
 
+
 [admin]
 dashboard=Dasbor
 organizations=Organisasi
diff --git a/options/locale/locale_is-IS.ini b/options/locale/locale_is-IS.ini
index 1eab4d58be..c8bcf9819e 100644
--- a/options/locale/locale_is-IS.ini
+++ b/options/locale/locale_is-IS.ini
@@ -49,6 +49,7 @@ webauthn_reload=Endurhlaða
 repository=Hugbúnaðarsafn
 organization=Stofnun
 mirror=Speglun
+issue_milestone=Tímamót
 new_repo=Nýtt Hugbúnaðarsafn
 new_migrate=Nýr Flutningur
 new_mirror=Ný Speglun
@@ -652,6 +653,7 @@ projects=Verkefni
 packages=Pakkar
 labels=Skýringar
 
+milestone=Tímamót
 milestones=Tímamót
 commits=Framlög
 commit=Framlag
@@ -1137,6 +1139,7 @@ teams.update_settings=Uppfæra Stillingar
 teams.all_repositories=Öll hugbúnaðarsöfn
 
 
+
 [admin]
 repositories=Hugbúnaðarsöfn
 config=Stilling
diff --git a/options/locale/locale_it-IT.ini b/options/locale/locale_it-IT.ini
index 17f0aa83d2..f37598eec3 100644
--- a/options/locale/locale_it-IT.ini
+++ b/options/locale/locale_it-IT.ini
@@ -50,6 +50,7 @@ webauthn_reload=Ricarica
 repository=Repository
 organization=Organizzazione
 mirror=Mirror
+issue_milestone=Traguardo
 new_repo=Nuovo Repository
 new_migrate=Nuova Migrazione
 new_mirror=Nuovo Mirror
@@ -942,6 +943,7 @@ labels=Etichette
 org_labels_desc=Etichette a livello di organizzazione che possono essere utilizzate con <strong>tutti i repository</strong> sotto questa organizzazione
 org_labels_desc_manage=gestisci
 
+milestone=Traguardo
 milestones=Traguardi
 commits=Commit
 commit=Commit
@@ -2154,6 +2156,7 @@ teams.all_repositories_write_permission_desc=Questo team concede <strong>permess
 teams.all_repositories_admin_permission_desc=Questo team concede a <strong>Amministratore</strong> l'accesso a <strong>tutte le repository</strong>: i membri possono leggere, pushare e aggiungere collaboratori alle repository.
 
 
+
 [admin]
 dashboard=Pannello di Controllo
 users=Account utenti
diff --git a/options/locale/locale_ja-JP.ini b/options/locale/locale_ja-JP.ini
index 925d0249b7..df0c2b9524 100644
--- a/options/locale/locale_ja-JP.ini
+++ b/options/locale/locale_ja-JP.ini
@@ -54,6 +54,7 @@ webauthn_reload=リロード
 repository=リポジトリ
 organization=組織
 mirror=ミラー
+issue_milestone=マイルストーン
 new_repo=新しいリポジトリ
 new_migrate=新しい移行
 new_mirror=新しいミラー
@@ -1253,6 +1254,7 @@ labels=ラベル
 org_labels_desc=組織で定義されているラベル (組織の<strong>すべてのリポジトリ</strong>で使用可能なもの)
 org_labels_desc_manage=編集
 
+milestone=マイルストーン
 milestones=マイルストーン
 commits=コミット
 commit=コミット
@@ -2873,6 +2875,7 @@ view_as_role=表示: %s
 view_as_public_hint=READMEを公開ユーザーとして見ています。
 view_as_member_hint=READMEをこの組織のメンバーとして見ています。
 
+
 [admin]
 maintenance=メンテナンス
 dashboard=ダッシュボード
diff --git a/options/locale/locale_ko-KR.ini b/options/locale/locale_ko-KR.ini
index 5485a53c81..05a6d21335 100644
--- a/options/locale/locale_ko-KR.ini
+++ b/options/locale/locale_ko-KR.ini
@@ -1191,6 +1191,7 @@ teams.add_duplicate_users=사용자가 이미 팀 멤버입니다.
 teams.members.none=이 팀에 멤버가 없습니다.
 
 
+
 [admin]
 dashboard=대시보드
 users=사용자 계정
diff --git a/options/locale/locale_lv-LV.ini b/options/locale/locale_lv-LV.ini
index cc2dcd1180..6fc6e53800 100644
--- a/options/locale/locale_lv-LV.ini
+++ b/options/locale/locale_lv-LV.ini
@@ -54,6 +54,7 @@ webauthn_reload=Pārlādēt
 repository=Repozitorijs
 organization=Organizācija
 mirror=Spogulis
+issue_milestone=Atskaites punktus
 new_repo=Jauns repozitorijs
 new_migrate=Jauna migrācija
 new_mirror=Jauns spogulis
@@ -1125,6 +1126,7 @@ labels=Iezīmes
 org_labels_desc=Organizācijas līmeņa iezīmes var tikt izmantotas <strong>visiem repozitorijiem</strong> šajā organizācijā
 org_labels_desc_manage=pārvaldīt
 
+milestone=Atskaites punktus
 milestones=Atskaites punkti
 commits=Revīzijas
 commit=Revīzija
@@ -2593,6 +2595,7 @@ teams.invite.by=Uzaicināja %s
 teams.invite.description=Nospiediet pogu zemāk, lai pievienotos komandai.
 
 
+
 [admin]
 dashboard=Infopanelis
 self_check=Pašpārbaude
diff --git a/options/locale/locale_nl-NL.ini b/options/locale/locale_nl-NL.ini
index 8a6dabbceb..9f61d34bc0 100644
--- a/options/locale/locale_nl-NL.ini
+++ b/options/locale/locale_nl-NL.ini
@@ -50,6 +50,7 @@ webauthn_reload=Vernieuwen
 repository=Repository
 organization=Organisatie
 mirror=Kopie
+issue_milestone=Mijlpaal
 new_repo=Nieuwe repository
 new_migrate=Nieuwe migratie
 new_mirror=Nieuwe kopie
@@ -940,6 +941,7 @@ labels=Labels
 org_labels_desc=Organisatielabel dat gebruikt kan worden met <strong>alle repositories</strong> onder deze organisatie
 org_labels_desc_manage=beheren
 
+milestone=Mijlpaal
 milestones=Mijlpalen
 commits=Commits
 commit=Commit
@@ -2055,6 +2057,7 @@ teams.all_repositories_helper=Team heeft toegang tot alle repositories. Door dit
 teams.all_repositories_read_permission_desc=Dit team heeft <strong>Lees</strong> toegang tot <strong>alle repositories</strong>: leden kunnen repositories bekijken en klonen.
 
 
+
 [admin]
 dashboard=Overzicht
 users=Gebruikersacount
diff --git a/options/locale/locale_pl-PL.ini b/options/locale/locale_pl-PL.ini
index 4dfae86bb6..958310a085 100644
--- a/options/locale/locale_pl-PL.ini
+++ b/options/locale/locale_pl-PL.ini
@@ -1934,6 +1934,7 @@ teams.all_repositories_write_permission_desc=Ten zespół nadaje uprawnienie <st
 teams.all_repositories_admin_permission_desc=Ten zespół nadaje uprawnienia <strong>Administratora</strong> do <strong>wszystkich repozytoriów</strong>: jego członkowie mogą odczytywać, przesyłać oraz dodawać innych współtwórców do repozytoriów.
 
 
+
 [admin]
 dashboard=Pulpit
 users=Konta użytkownika
diff --git a/options/locale/locale_pt-BR.ini b/options/locale/locale_pt-BR.ini
index cc21c5abea..9df7e5e956 100644
--- a/options/locale/locale_pt-BR.ini
+++ b/options/locale/locale_pt-BR.ini
@@ -52,6 +52,7 @@ webauthn_reload=Recarregar
 repository=Repositório
 organization=Organização
 mirror=Espelhamento
+issue_milestone=Marco
 new_repo=Novo repositório
 new_migrate=Nova migração
 new_mirror=Novo espelhamento
@@ -1119,6 +1120,7 @@ labels=Etiquetas
 org_labels_desc=Rótulos de nível de organização que podem ser usados em <strong>todos os repositórios</strong> sob esta organização
 org_labels_desc_manage=gerenciar
 
+milestone=Marco
 milestones=Marcos
 commits=Commits
 commit=Commit
@@ -2551,6 +2553,7 @@ teams.invite.by=Convidado por %s
 teams.invite.description=Por favor, clique no botão abaixo para se juntar à equipe.
 
 
+
 [admin]
 dashboard=Painel
 identity_access=Identidade e acesso
diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini
index 88308271a7..ba883dc7f9 100644
--- a/options/locale/locale_pt-PT.ini
+++ b/options/locale/locale_pt-PT.ini
@@ -54,6 +54,7 @@ webauthn_reload=Recarregar
 repository=Repositório
 organization=Organização
 mirror=Réplica
+issue_milestone=Etapa
 new_repo=Novo repositório
 new_migrate=Nova migração
 new_mirror=Nova réplica
@@ -1253,6 +1254,7 @@ labels=Rótulos
 org_labels_desc=Rótulos ao nível da organização que podem ser usados em <strong>todos os repositórios</strong> desta organização
 org_labels_desc_manage=gerir
 
+milestone=Etapa
 milestones=Etapas
 commits=Cometimentos
 commit=Cometimento
@@ -1345,6 +1347,8 @@ editor.new_branch_name_desc=Nome do novo ramo…
 editor.cancel=Cancelar
 editor.filename_cannot_be_empty=O nome do ficheiro não pode estar em branco.
 editor.filename_is_invalid=O nome do ficheiro é inválido: "%s".
+editor.commit_email=Email do cometimento
+editor.invalid_commit_email=O email do comentimento é inválido.
 editor.branch_does_not_exist=O ramo "%s" não existe neste repositório.
 editor.branch_already_exists=O ramo "%s" já existe neste repositório.
 editor.directory_is_a_file=O nome da pasta "%s" já é usado como um nome de ficheiro neste repositório.
@@ -2326,6 +2330,8 @@ settings.event_fork=Derivar
 settings.event_fork_desc=Feita a derivação do repositório.
 settings.event_wiki=Wiki
 settings.event_wiki_desc=Página do wiki criada, renomeada, editada ou eliminada.
+settings.event_statuses=Estados
+settings.event_statuses_desc=Estado do cometimento modificado através da API.
 settings.event_release=Lançamento
 settings.event_release_desc=Lançamento publicado, modificado ou eliminado num repositório.
 settings.event_push=Enviar
@@ -2873,6 +2879,15 @@ view_as_role=Ver como: %s
 view_as_public_hint=Está a ver o README como um utilizador público.
 view_as_member_hint=Está a ver o README como um membro desta organização.
 
+worktime=Tempo de trabalho
+worktime.date_range_start=Data do início
+worktime.date_range_end=Data do fim
+worktime.query=Consulta
+worktime.time=Tempo
+worktime.by_repositories=Por repositórios
+worktime.by_milestones=Por etapas
+worktime.by_members=Por membros
+
 [admin]
 maintenance=Manutenção
 dashboard=Painel de controlo
diff --git a/options/locale/locale_ru-RU.ini b/options/locale/locale_ru-RU.ini
index d3b673bd18..f2b4e28405 100644
--- a/options/locale/locale_ru-RU.ini
+++ b/options/locale/locale_ru-RU.ini
@@ -52,6 +52,7 @@ webauthn_reload=Обновить
 repository=Репозиторий
 organization=Организация
 mirror=Зеркало
+issue_milestone=Этап
 new_repo=Новый репозиторий
 new_migrate=Новая миграция
 new_mirror=Новое зеркало
@@ -1100,6 +1101,7 @@ labels=Метки
 org_labels_desc=Метки уровня организации, которые можно использовать с <strong>всеми репозиториями</strong> в этой организации
 org_labels_desc_manage=управлять
 
+milestone=Этап
 milestones=Этапы
 commits=коммитов
 commit=коммит
@@ -2540,6 +2542,7 @@ teams.invite.by=Приглашен(а) %s
 teams.invite.description=Нажмите на кнопку ниже, чтобы присоединиться к команде.
 
 
+
 [admin]
 dashboard=Панель
 identity_access=Идентификация и доступ
diff --git a/options/locale/locale_si-LK.ini b/options/locale/locale_si-LK.ini
index 167ecaf24a..2a0b9394d5 100644
--- a/options/locale/locale_si-LK.ini
+++ b/options/locale/locale_si-LK.ini
@@ -1955,6 +1955,7 @@ teams.all_repositories_write_permission_desc=මෙම කණ්ඩායම ප
 teams.all_repositories_admin_permission_desc=මෙම කණ්ඩායම ප්රදානය කරයි <strong>පරිපාලක</strong> වෙත ප්රවේශය <strong>සියලු ගබඩාවන්ට</strong>: සාමාජිකයින්ට කියවීමට, තල්ලු කිරීමට සහ ගබඩාවන්ට සහයෝගීකයින් එකතු කිරීමට.
 
 
+
 [admin]
 dashboard=උපකරණ පුවරුව
 users=පරිශීලක ගිණුම්
diff --git a/options/locale/locale_sk-SK.ini b/options/locale/locale_sk-SK.ini
index cd2f915755..e323c2befa 100644
--- a/options/locale/locale_sk-SK.ini
+++ b/options/locale/locale_sk-SK.ini
@@ -53,6 +53,7 @@ webauthn_reload=Znovu načítať
 repository=Repozitár
 organization=Organizácia
 mirror=Zrkadlo
+issue_milestone=Míľnik
 new_repo=Nový repozitár
 new_migrate=Nová migrácia
 new_mirror=Nové zrkadlo
@@ -967,6 +968,7 @@ labels=Štítky
 org_labels_desc=Štítky na úrovni organizácie, ktoré možno použiť so <strong>všetkými repozitármi</strong> v rámci tejto organizácie
 org_labels_desc_manage=spravovať
 
+milestone=Míľnik
 milestones=Míľniky
 commits=Commitov
 release=Vydanie
@@ -1236,6 +1238,7 @@ teams.all_repositories_write_permission_desc=Tomuto tímu je pridelený prístup
 teams.all_repositories_admin_permission_desc=Tomuto tímu je pridelený <strong>Admin</strong> prístup ku <strong>všetkým repozitárom</strong>: členovia môžu prezerať, nahrávať do repozitárov a pridávať do nich spolupracovníkov.
 
 
+
 [admin]
 repositories=Repozitáre
 hooks=Webhooky
diff --git a/options/locale/locale_sv-SE.ini b/options/locale/locale_sv-SE.ini
index 0315ebe9a1..d43cf655ae 100644
--- a/options/locale/locale_sv-SE.ini
+++ b/options/locale/locale_sv-SE.ini
@@ -1592,6 +1592,7 @@ teams.all_repositories_write_permission_desc=Detta team beviljar <strong>Skriv</
 teams.all_repositories_admin_permission_desc=Detta team beviljar <strong>Admin</strong>-rättigheter till <strong>alla utvecklingskataloger</strong>: medlemmar kan läsa från, pusha till och lägga till kollaboratörer för utvecklingskatalogerna.
 
 
+
 [admin]
 dashboard=Instrumentpanel
 users=Användarkonto
diff --git a/options/locale/locale_tr-TR.ini b/options/locale/locale_tr-TR.ini
index 6d14f512ff..39f5bd5978 100644
--- a/options/locale/locale_tr-TR.ini
+++ b/options/locale/locale_tr-TR.ini
@@ -54,6 +54,7 @@ webauthn_reload=Yeniden yükle
 repository=Depo
 organization=Organizasyon
 mirror=Yansı
+issue_milestone=Dönüm noktası
 new_repo=Yeni Depo
 new_migrate=Yeni Göç
 new_mirror=Yeni Yansı
@@ -78,7 +79,7 @@ forks=Çatallar
 activities=Etkinlikler
 pull_requests=Değişiklik İstekleri
 issues=Konular
-milestones=Kilometre Taşları
+milestones=Dönüm noktaları
 
 ok=Tamam
 cancel=İptal
@@ -1128,7 +1129,7 @@ migrate_options_lfs_endpoint.description.local=Yerel bir sunucu yolu da destekle
 migrate_options_lfs_endpoint.placeholder=Boş bırakılırsa, uç nokta klon URL'sinden türetilecektir
 migrate_items=Göç Öğeleri
 migrate_items_wiki=Wiki
-migrate_items_milestones=Kilometre Taşları
+migrate_items_milestones=Dönüm noktaları
 migrate_items_labels=Etiketler
 migrate_items_issues=Konular
 migrate_items_pullrequests=Değişiklik İstekleri
@@ -1212,6 +1213,7 @@ labels=Etiketler
 org_labels_desc=Bu organizasyon altında <strong>tüm depolarla</strong> kullanılabilen organizasyon düzeyinde etiketler
 org_labels_desc_manage=yönet
 
+milestone=Dönüm noktası
 milestones=Kilometre Taşları
 commits=İşleme
 commit=İşle
@@ -2752,6 +2754,7 @@ teams.invite.by=%s tarafından davet edildi
 teams.invite.description=Takıma katılmak için aşağıdaki düğmeye tıklayın.
 
 
+
 [admin]
 maintenance=Bakım
 dashboard=Pano
diff --git a/options/locale/locale_uk-UA.ini b/options/locale/locale_uk-UA.ini
index 2b0e57c8e0..011a86abf6 100644
--- a/options/locale/locale_uk-UA.ini
+++ b/options/locale/locale_uk-UA.ini
@@ -37,6 +37,7 @@ webauthn_reload=Оновити
 repository=Репозиторій
 organization=Організація
 mirror=Дзеркало
+issue_milestone=Етап
 new_repo=Новий репозиторій
 new_migrate=Нова міграція
 new_mirror=Нове дзеркало
@@ -889,6 +890,7 @@ labels=Мітки
 org_labels_desc=Мітки рівня організації можуть використовуватися <strong>в усіх репозиторіях</strong> цієї організації
 org_labels_desc_manage=керувати
 
+milestone=Етап
 milestones=Етап
 commits=Коміти
 commit=Коміт
@@ -2003,6 +2005,7 @@ teams.all_repositories_write_permission_desc=Ця команда надає до
 teams.all_repositories_admin_permission_desc=Ця команда надає дозвіл <strong>Адміністрування</strong> для <strong>всіх репозиторіїв</strong>: учасники можуть переглядати, виконувати push та додавати співробітників.
 
 
+
 [admin]
 dashboard=Панель управління
 users=Облікові записи користувачів
diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini
index 92de8a1280..a1c3d75913 100644
--- a/options/locale/locale_zh-CN.ini
+++ b/options/locale/locale_zh-CN.ini
@@ -54,6 +54,7 @@ webauthn_reload=重新加载
 repository=仓库
 organization=组织
 mirror=镜像
+issue_milestone=里程碑
 new_repo=创建仓库
 new_migrate=迁移外部仓库
 new_mirror=创建新的镜像
@@ -1247,6 +1248,7 @@ labels=标签
 org_labels_desc=组织级别的标签,可以被本组织下的 <strong>所有仓库</strong> 使用
 org_labels_desc_manage=管理
 
+milestone=里程碑
 milestones=里程碑
 commits=提交
 commit=提交
@@ -2854,6 +2856,7 @@ teams.invite.by=邀请人 %s
 teams.invite.description=请点击下面的按钮加入团队。
 
 
+
 [admin]
 maintenance=维护
 dashboard=管理面板
diff --git a/options/locale/locale_zh-HK.ini b/options/locale/locale_zh-HK.ini
index 77f8d8a25d..eb11ee3a4a 100644
--- a/options/locale/locale_zh-HK.ini
+++ b/options/locale/locale_zh-HK.ini
@@ -685,6 +685,7 @@ teams.delete_team_success=該團隊已被刪除。
 teams.repositories=團隊儲存庫
 
 
+
 [admin]
 dashboard=控制面版
 organizations=組織管理
diff --git a/options/locale/locale_zh-TW.ini b/options/locale/locale_zh-TW.ini
index d03d9cf1fa..b84aaf1e1f 100644
--- a/options/locale/locale_zh-TW.ini
+++ b/options/locale/locale_zh-TW.ini
@@ -54,6 +54,7 @@ webauthn_reload=重新載入
 repository=儲存庫
 organization=組織
 mirror=鏡像
+issue_milestone=里程碑
 new_repo=新增儲存庫
 new_migrate=遷移外部儲存庫
 new_mirror=新鏡像
@@ -1241,6 +1242,7 @@ labels=標籤
 org_labels_desc=組織層級標籤可用於此組織下的<strong>所有存儲庫</strong>。
 org_labels_desc_manage=管理
 
+milestone=里程碑
 milestones=里程碑
 commits=提交歷史
 commit=提交
@@ -2845,6 +2847,7 @@ teams.invite.by=邀請人 %s
 teams.invite.description=請點擊下方按鈕加入團隊。
 
 
+
 [admin]
 maintenance=維護
 dashboard=資訊主頁
diff --git a/public/assets/img/feishu.png b/public/assets/img/feishu.png
deleted file mode 100644
index 2c3ab74413..0000000000
Binary files a/public/assets/img/feishu.png and /dev/null differ
diff --git a/public/assets/img/svg/gitea-feishu.svg b/public/assets/img/svg/gitea-feishu.svg
new file mode 100644
index 0000000000..d7a5ead499
--- /dev/null
+++ b/public/assets/img/svg/gitea-feishu.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="7 7 26 26" class="svg gitea-feishu" width="16" height="16" aria-hidden="true"><path fill="#00d6b9" d="m21.069 20.504.063-.06.125-.122.085-.084.256-.254.348-.344.299-.296.281-.278.293-.289.269-.266.374-.37.218-.206.419-.359.404-.306.598-.386.617-.33.606-.265.348-.127.177-.058a14.8 14.8 0 0 0-2.793-5.603 1.34 1.34 0 0 0-1.047-.502H12.221a.201.201 0 0 0-.119.364 31.5 31.5 0 0 1 8.943 10.162l.025-.023z"/><path fill="#3370ff" d="M16.791 30c5.57 0 10.423-3.074 12.955-7.618q.133-.239.258-.484a6 6 0 0 1-.425.699 6 6 0 0 1-.17.23 6 6 0 0 1-.225.274q-.092.105-.188.206a6 6 0 0 1-.407.384 6 6 0 0 1-.24.195 7 7 0 0 1-.292.21q-.094.065-.191.122c-.097.057-.134.081-.204.119q-.21.116-.428.215a6 6 0 0 1-.385.157 6 6 0 0 1-.43.138 6 6 0 0 1-.661.143 6 6 0 0 1-.491.055 6.125 6.125 0 0 1-1.543-.085 7 7 0 0 1-.38-.079l-.2-.051-.555-.155-.275-.081-.41-.125-.334-.107-.317-.104-.215-.073-.26-.091-.186-.066-.367-.134-.212-.081-.284-.11-.299-.119-.193-.079-.24-.1-.185-.078-.192-.084-.166-.073-.152-.067-.153-.07-.159-.073-.2-.093-.208-.099-.222-.108-.189-.093a31.2 31.2 0 0 1-8.822-6.583.202.202 0 0 0-.349.138l.005 9.52v.773c0 .448.222.87.595 1.118A14.75 14.75 0 0 0 16.791 30"/><path fill="#133c92" d="m29.746 22.382.051-.093zm.231-.435.014-.025.007-.012z"/><path fill="#133c9a" d="M33.151 16.582a8.45 8.45 0 0 0-3.744-.869 8.5 8.5 0 0 0-2.303.317l-.252.075-.177.058-.348.127-.606.265-.617.33-.598.386-.404.306-.419.359-.218.206-.374.37-.269.266-.293.289-.281.278-.299.296-.348.344-.256.254-.085.084-.125.122-.063.06-.095.09-.105.099a15 15 0 0 1-3.072 2.175l.2.093.159.073.153.07.152.067.166.073.192.084.185.078.24.1.193.079.299.119.284.11.212.081.367.134.186.066.26.09.215.073.317.104.334.107.41.125.275.081.555.155.2.051.379.079.433.062.585.037.525-.014.491-.055a6 6 0 0 0 .66-.143l.43-.138.385-.158.427-.215.204-.119.191-.122.292-.21.24-.195.407-.384.188-.206.225-.274.17-.23a6 6 0 0 0 .421-.693l.144-.288 1.305-2.599-.003.006a8.1 8.1 0 0 1 1.697-2.439z"/></svg>
\ No newline at end of file
diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go
index 21cb2f9ccd..53eee72631 100644
--- a/routers/api/v1/admin/user.go
+++ b/routers/api/v1/admin/user.go
@@ -477,26 +477,16 @@ func RenameUser(ctx *context.APIContext) {
 		return
 	}
 
-	oldName := ctx.ContextUser.Name
 	newName := web.GetForm(ctx).(*api.RenameUserOption).NewName
 
-	// Check if user name has been changed
+	// Check if username has been changed
 	if err := user_service.RenameUser(ctx, ctx.ContextUser, newName); err != nil {
-		switch {
-		case user_model.IsErrUserAlreadyExist(err):
-			ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("form.username_been_taken"))
-		case db.IsErrNameReserved(err):
-			ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("user.form.name_reserved", newName))
-		case db.IsErrNamePatternNotAllowed(err):
-			ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("user.form.name_pattern_not_allowed", newName))
-		case db.IsErrNameCharsNotAllowed(err):
-			ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("user.form.name_chars_not_allowed", newName))
-		default:
+		if user_model.IsErrUserAlreadyExist(err) || db.IsErrNameReserved(err) || db.IsErrNamePatternNotAllowed(err) || db.IsErrNameCharsNotAllowed(err) {
+			ctx.Error(http.StatusUnprocessableEntity, "", err)
+		} else {
 			ctx.ServerError("ChangeUserName", err)
 		}
 		return
 	}
-
-	log.Trace("User name changed: %s -> %s", oldName, newName)
 	ctx.Status(http.StatusNoContent)
 }
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index b1a42a85e6..8d9e4bfd6c 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -268,12 +268,12 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) {
 				return
 			}
 		case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser):
-			if ctx.ContextUser != nil && ctx.ContextUser.IsUser() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
+			if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
 				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public users")
 				return
 			}
 		case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub):
-			if ctx.ContextUser != nil && ctx.ContextUser.IsUser() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
+			if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
 				ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public activitypub")
 				return
 			}
@@ -580,6 +580,16 @@ func reqWebhooksEnabled() func(ctx *context.APIContext) {
 	}
 }
 
+// reqStarsEnabled requires Starring to be enabled in the config.
+func reqStarsEnabled() func(ctx *context.APIContext) {
+	return func(ctx *context.APIContext) {
+		if setting.Repository.DisableStars {
+			ctx.Error(http.StatusForbidden, "", "stars disabled by administrator")
+			return
+		}
+	}
+}
+
 func orgAssignment(args ...bool) func(ctx *context.APIContext) {
 	var (
 		assignOrg  bool
@@ -995,7 +1005,7 @@ func Routes() *web.Router {
 					m.Get("/{target}", user.CheckFollowing)
 				})
 
-				m.Get("/starred", user.GetStarredRepos)
+				m.Get("/starred", reqStarsEnabled(), user.GetStarredRepos)
 
 				m.Get("/subscriptions", user.GetWatchedRepos)
 			}, context.UserAssignmentAPI(), checkTokenPublicOnly())
@@ -1086,7 +1096,7 @@ func Routes() *web.Router {
 					m.Put("", user.Star)
 					m.Delete("", user.Unstar)
 				}, repoAssignment(), checkTokenPublicOnly())
-			}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
+			}, reqStarsEnabled(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
 			m.Get("/times", repo.ListMyTrackedTimes)
 			m.Get("/stopwatches", repo.GetStopwatches)
 			m.Get("/subscriptions", user.GetMyWatchedRepos)
@@ -1145,11 +1155,17 @@ func Routes() *web.Router {
 					m.Post("/accept", repo.AcceptTransfer)
 					m.Post("/reject", repo.RejectTransfer)
 				}, reqToken())
-				addActionsRoutes(
-					m,
-					reqOwner(),
-					repo.NewAction(),
-				)
+
+				addActionsRoutes(m, reqOwner(), repo.NewAction()) // it adds the routes for secrets/variables and runner management
+
+				m.Group("/actions/workflows", func() {
+					m.Get("", repo.ActionsListRepositoryWorkflows)
+					m.Get("/{workflow_id}", repo.ActionsGetWorkflow)
+					m.Put("/{workflow_id}/disable", reqRepoWriter(unit.TypeActions), repo.ActionsDisableWorkflow)
+					m.Put("/{workflow_id}/enable", reqRepoWriter(unit.TypeActions), repo.ActionsEnableWorkflow)
+					m.Post("/{workflow_id}/dispatches", reqRepoWriter(unit.TypeActions), bind(api.CreateActionWorkflowDispatch{}), repo.ActionsDispatchWorkflow)
+				}, context.ReferencesGitRepo(), reqToken(), reqRepoReader(unit.TypeActions))
+
 				m.Group("/hooks/git", func() {
 					m.Combo("").Get(repo.ListGitHooks)
 					m.Group("/{id}", func() {
@@ -1248,7 +1264,7 @@ func Routes() *web.Router {
 				m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup)
 				m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown)
 				m.Post("/markdown/raw", reqToken(), misc.MarkdownRaw)
-				m.Get("/stargazers", repo.ListStargazers)
+				m.Get("/stargazers", reqStarsEnabled(), repo.ListStargazers)
 				m.Get("/subscribers", repo.ListSubscribers)
 				m.Group("/subscription", func() {
 					m.Get("", user.IsWatching)
@@ -1530,6 +1546,7 @@ func Routes() *web.Router {
 			m.Combo("").Get(org.Get).
 				Patch(reqToken(), reqOrgOwnership(), bind(api.EditOrgOption{}), org.Edit).
 				Delete(reqToken(), reqOrgOwnership(), org.Delete)
+			m.Post("/rename", reqToken(), reqOrgOwnership(), bind(api.RenameOrgOption{}), org.Rename)
 			m.Combo("/repos").Get(user.ListOrgRepos).
 				Post(reqToken(), bind(api.CreateRepoOption{}), repo.CreateOrgRepo)
 			m.Group("/members", func() {
diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go
index 199ee7d777..05919c5234 100644
--- a/routers/api/v1/org/action.go
+++ b/routers/api/v1/org/action.go
@@ -450,7 +450,11 @@ func (Action) UpdateVariable(ctx *context.APIContext) {
 	if opt.Name == "" {
 		opt.Name = ctx.PathParam("variablename")
 	}
-	if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil {
+
+	v.Name = opt.Name
+	v.Data = opt.Value
+
+	if _, err := actions_service.UpdateVariableNameData(ctx, v); err != nil {
 		if errors.Is(err, util.ErrInvalidArgument) {
 			ctx.Error(http.StatusBadRequest, "UpdateVariable", err)
 		} else {
diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go
index d65f922434..2fcba0bf1a 100644
--- a/routers/api/v1/org/org.go
+++ b/routers/api/v1/org/org.go
@@ -315,6 +315,44 @@ func Get(ctx *context.APIContext) {
 	ctx.JSON(http.StatusOK, org)
 }
 
+func Rename(ctx *context.APIContext) {
+	// swagger:operation POST /orgs/{org}/rename organization renameOrg
+	// ---
+	// summary: Rename an organization
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: existing org name
+	//   type: string
+	//   required: true
+	// - name: body
+	//   in: body
+	//   required: true
+	//   schema:
+	//     "$ref": "#/definitions/RenameOrgOption"
+	// responses:
+	//   "204":
+	//     "$ref": "#/responses/empty"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	form := web.GetForm(ctx).(*api.RenameOrgOption)
+	orgUser := ctx.Org.Organization.AsUser()
+	if err := user_service.RenameUser(ctx, orgUser, form.NewName); err != nil {
+		if user_model.IsErrUserAlreadyExist(err) || db.IsErrNameReserved(err) || db.IsErrNamePatternNotAllowed(err) || db.IsErrNameCharsNotAllowed(err) {
+			ctx.Error(http.StatusUnprocessableEntity, "RenameOrg", err)
+		} else {
+			ctx.ServerError("RenameOrg", err)
+		}
+		return
+	}
+	ctx.Status(http.StatusNoContent)
+}
+
 // Edit change an organization's information
 func Edit(ctx *context.APIContext) {
 	// swagger:operation PATCH /orgs/{org} organization orgEdit
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index d27e8d2427..850384e778 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -6,6 +6,7 @@ package repo
 import (
 	"errors"
 	"net/http"
+	"strings"
 
 	actions_model "code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/models/db"
@@ -19,6 +20,8 @@ import (
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/convert"
 	secret_service "code.gitea.io/gitea/services/secrets"
+
+	"github.com/nektos/act/pkg/model"
 )
 
 // ListActionsSecrets list an repo's actions secrets
@@ -414,7 +417,11 @@ func (Action) UpdateVariable(ctx *context.APIContext) {
 	if opt.Name == "" {
 		opt.Name = ctx.PathParam("variablename")
 	}
-	if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil {
+
+	v.Name = opt.Name
+	v.Data = opt.Value
+
+	if _, err := actions_service.UpdateVariableNameData(ctx, v); err != nil {
 		if errors.Is(err, util.ErrInvalidArgument) {
 			ctx.Error(http.StatusBadRequest, "UpdateVariable", err)
 		} else {
@@ -581,3 +588,270 @@ func ListActionTasks(ctx *context.APIContext) {
 
 	ctx.JSON(http.StatusOK, &res)
 }
+
+func ActionsListRepositoryWorkflows(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/actions/workflows repository ActionsListRepositoryWorkflows
+	// ---
+	// summary: List repository workflows
+	// 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
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/ActionWorkflowList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+	//   "500":
+	//     "$ref": "#/responses/error"
+
+	workflows, err := actions_service.ListActionWorkflows(ctx)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, "ListActionWorkflows", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, &api.ActionWorkflowResponse{Workflows: workflows, TotalCount: int64(len(workflows))})
+}
+
+func ActionsGetWorkflow(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/actions/workflows/{workflow_id} repository ActionsGetWorkflow
+	// ---
+	// summary: Get a workflow
+	// 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: workflow_id
+	//   in: path
+	//   description: id of the workflow
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/ActionWorkflow"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+	//   "500":
+	//     "$ref": "#/responses/error"
+
+	workflowID := ctx.PathParam("workflow_id")
+	workflow, err := actions_service.GetActionWorkflow(ctx, workflowID)
+	if err != nil {
+		if errors.Is(err, util.ErrNotExist) {
+			ctx.Error(http.StatusNotFound, "GetActionWorkflow", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "GetActionWorkflow", err)
+		}
+		return
+	}
+
+	ctx.JSON(http.StatusOK, workflow)
+}
+
+func ActionsDisableWorkflow(ctx *context.APIContext) {
+	// swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable repository ActionsDisableWorkflow
+	// ---
+	// summary: Disable a workflow
+	// 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: workflow_id
+	//   in: path
+	//   description: id of the workflow
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     description: No Content
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	workflowID := ctx.PathParam("workflow_id")
+	err := actions_service.EnableOrDisableWorkflow(ctx, workflowID, false)
+	if err != nil {
+		if errors.Is(err, util.ErrNotExist) {
+			ctx.Error(http.StatusNotFound, "DisableActionWorkflow", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "DisableActionWorkflow", err)
+		}
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+func ActionsDispatchWorkflow(ctx *context.APIContext) {
+	// swagger:operation POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches repository ActionsDispatchWorkflow
+	// ---
+	// summary: Create a workflow dispatch event
+	// 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: workflow_id
+	//   in: path
+	//   description: id of the workflow
+	//   type: string
+	//   required: true
+	// - name: body
+	//   in: body
+	//   schema:
+	//     "$ref": "#/definitions/CreateActionWorkflowDispatch"
+	// responses:
+	//   "204":
+	//     description: No Content
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	workflowID := ctx.PathParam("workflow_id")
+	opt := web.GetForm(ctx).(*api.CreateActionWorkflowDispatch)
+	if opt.Ref == "" {
+		ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("ref is required parameter"))
+		return
+	}
+
+	err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, opt.Ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error {
+		if strings.Contains(ctx.Req.Header.Get("Content-Type"), "form-urlencoded") {
+			// The chi framework's "Binding" doesn't support to bind the form map values into a map[string]string
+			// So we have to manually read the `inputs[key]` from the form
+			for name, config := range workflowDispatch.Inputs {
+				value := ctx.FormString("inputs["+name+"]", config.Default)
+				inputs[name] = value
+			}
+		} else {
+			for name, config := range workflowDispatch.Inputs {
+				value, ok := opt.Inputs[name]
+				if ok {
+					inputs[name] = value
+				} else {
+					inputs[name] = config.Default
+				}
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		if errors.Is(err, util.ErrNotExist) {
+			ctx.Error(http.StatusNotFound, "DispatchActionWorkflow", err)
+		} else if errors.Is(err, util.ErrPermissionDenied) {
+			ctx.Error(http.StatusForbidden, "DispatchActionWorkflow", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "DispatchActionWorkflow", err)
+		}
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+func ActionsEnableWorkflow(ctx *context.APIContext) {
+	// swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/enable repository ActionsEnableWorkflow
+	// ---
+	// summary: Enable a workflow
+	// 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: workflow_id
+	//   in: path
+	//   description: id of the workflow
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     description: No Content
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "409":
+	//     "$ref": "#/responses/conflict"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	workflowID := ctx.PathParam("workflow_id")
+	err := actions_service.EnableOrDisableWorkflow(ctx, workflowID, true)
+	if err != nil {
+		if errors.Is(err, util.ErrNotExist) {
+			ctx.Error(http.StatusNotFound, "EnableActionWorkflow", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "EnableActionWorkflow", err)
+		}
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/repo/star.go b/routers/api/v1/repo/star.go
index 99676de119..46ed17ad91 100644
--- a/routers/api/v1/repo/star.go
+++ b/routers/api/v1/repo/star.go
@@ -44,6 +44,8 @@ func ListStargazers(ctx *context.APIContext) {
 	//     "$ref": "#/responses/UserList"
 	//   "404":
 	//     "$ref": "#/responses/notFound"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
 
 	stargazers, err := repo_model.GetStargazers(ctx, ctx.Repo.Repository, utils.GetListOptions(ctx))
 	if err != nil {
diff --git a/routers/api/v1/swagger/action.go b/routers/api/v1/swagger/action.go
index 665f4d0b85..16a250184a 100644
--- a/routers/api/v1/swagger/action.go
+++ b/routers/api/v1/swagger/action.go
@@ -32,3 +32,17 @@ type swaggerResponseVariableList struct {
 	// in:body
 	Body []api.ActionVariable `json:"body"`
 }
+
+// ActionWorkflow
+// swagger:response ActionWorkflow
+type swaggerResponseActionWorkflow struct {
+	// in:body
+	Body api.ActionWorkflow `json:"body"`
+}
+
+// ActionWorkflowList
+// swagger:response ActionWorkflowList
+type swaggerResponseActionWorkflowList struct {
+	// in:body
+	Body []api.ActionWorkflow `json:"body"`
+}
diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go
index 125605d98f..aa5990eb38 100644
--- a/routers/api/v1/swagger/options.go
+++ b/routers/api/v1/swagger/options.go
@@ -208,6 +208,12 @@ type swaggerParameterBodies struct {
 	// in:body
 	CreateVariableOption api.CreateVariableOption
 
+	// in:body
+	RenameOrgOption api.RenameOrgOption
+
+	// in:body
+	CreateActionWorkflowDispatch api.CreateActionWorkflowDispatch
+
 	// in:body
 	UpdateVariableOption api.UpdateVariableOption
 }
diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go
index 22707196f4..baa4b3b81e 100644
--- a/routers/api/v1/user/action.go
+++ b/routers/api/v1/user/action.go
@@ -212,7 +212,11 @@ func UpdateVariable(ctx *context.APIContext) {
 	if opt.Name == "" {
 		opt.Name = ctx.PathParam("variablename")
 	}
-	if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil {
+
+	v.Name = opt.Name
+	v.Data = opt.Value
+
+	if _, err := actions_service.UpdateVariableNameData(ctx, v); err != nil {
 		if errors.Is(err, util.ErrInvalidArgument) {
 			ctx.Error(http.StatusBadRequest, "UpdateVariable", err)
 		} else {
diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go
index ad9ed9548d..70e54bc1ae 100644
--- a/routers/api/v1/user/star.go
+++ b/routers/api/v1/user/star.go
@@ -66,6 +66,8 @@ func GetStarredRepos(ctx *context.APIContext) {
 	//     "$ref": "#/responses/RepositoryList"
 	//   "404":
 	//     "$ref": "#/responses/notFound"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
 
 	private := ctx.ContextUser.ID == ctx.Doer.ID
 	repos, err := getStarredRepos(ctx, ctx.ContextUser, private)
@@ -97,6 +99,8 @@ func GetMyStarredRepos(ctx *context.APIContext) {
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/RepositoryList"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
 
 	repos, err := getStarredRepos(ctx, ctx.Doer, true)
 	if err != nil {
@@ -128,6 +132,8 @@ func IsStarring(ctx *context.APIContext) {
 	//     "$ref": "#/responses/empty"
 	//   "404":
 	//     "$ref": "#/responses/notFound"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
 
 	if repo_model.IsStaring(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) {
 		ctx.Status(http.StatusNoContent)
@@ -193,6 +199,8 @@ func Unstar(ctx *context.APIContext) {
 	//     "$ref": "#/responses/empty"
 	//   "404":
 	//     "$ref": "#/responses/notFound"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
 
 	err := repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, false)
 	if err != nil {
diff --git a/routers/web/org/home.go b/routers/web/org/home.go
index 277adb60ca..27d1e14d85 100644
--- a/routers/web/org/home.go
+++ b/routers/web/org/home.go
@@ -34,7 +34,7 @@ func Home(ctx *context.Context) {
 	}
 
 	ctx.SetPathParam("org", uname)
-	context.HandleOrgAssignment(ctx)
+	context.OrgAssignment(context.OrgAssignmentOptions{})(ctx)
 	if ctx.Written() {
 		return
 	}
diff --git a/routers/web/org/worktime.go b/routers/web/org/worktime.go
new file mode 100644
index 0000000000..2336984825
--- /dev/null
+++ b/routers/web/org/worktime.go
@@ -0,0 +1,74 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package org
+
+import (
+	"net/http"
+	"time"
+
+	"code.gitea.io/gitea/models/organization"
+	"code.gitea.io/gitea/modules/templates"
+	"code.gitea.io/gitea/services/context"
+)
+
+const tplByRepos templates.TplName = "org/worktime"
+
+// parseOrgTimes contains functionality that is required in all these functions,
+// like parsing the date from the request, setting default dates, etc.
+func parseOrgTimes(ctx *context.Context) (unixFrom, unixTo int64) {
+	rangeFrom := ctx.FormString("from")
+	rangeTo := ctx.FormString("to")
+	if rangeFrom == "" {
+		rangeFrom = time.Now().Format("2006-01") + "-01" // defaults to start of current month
+	}
+	if rangeTo == "" {
+		rangeTo = time.Now().Format("2006-01-02") // defaults to today
+	}
+
+	ctx.Data["RangeFrom"] = rangeFrom
+	ctx.Data["RangeTo"] = rangeTo
+
+	timeFrom, err := time.Parse("2006-01-02", rangeFrom)
+	if err != nil {
+		ctx.ServerError("time.Parse", err)
+	}
+	timeTo, err := time.Parse("2006-01-02", rangeTo)
+	if err != nil {
+		ctx.ServerError("time.Parse", err)
+	}
+	unixFrom = timeFrom.Unix()
+	unixTo = timeTo.Add(1440*time.Minute - 1*time.Second).Unix() // humans expect that we include the ending day too
+	return unixFrom, unixTo
+}
+
+func Worktime(ctx *context.Context) {
+	ctx.Data["PageIsOrgTimes"] = true
+
+	unixFrom, unixTo := parseOrgTimes(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	worktimeBy := ctx.FormString("by")
+	ctx.Data["WorktimeBy"] = worktimeBy
+
+	var worktimeSumResult any
+	var err error
+	if worktimeBy == "milestones" {
+		worktimeSumResult, err = organization.GetWorktimeByMilestones(ctx.Org.Organization, unixFrom, unixTo)
+		ctx.Data["WorktimeByMilestones"] = true
+	} else if worktimeBy == "members" {
+		worktimeSumResult, err = organization.GetWorktimeByMembers(ctx.Org.Organization, unixFrom, unixTo)
+		ctx.Data["WorktimeByMembers"] = true
+	} else /* by repos */ {
+		worktimeSumResult, err = organization.GetWorktimeByRepos(ctx.Org.Organization, unixFrom, unixTo)
+		ctx.Data["WorktimeByRepos"] = true
+	}
+	if err != nil {
+		ctx.ServerError("GetWorktime", err)
+		return
+	}
+	ctx.Data["WorktimeSumResult"] = worktimeSumResult
+	ctx.HTML(http.StatusOK, tplByRepos)
+}
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index e5d83960b8..7099582c1b 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -20,8 +20,6 @@ import (
 	actions_model "code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/models/db"
 	git_model "code.gitea.io/gitea/models/git"
-	"code.gitea.io/gitea/models/perm"
-	access_model "code.gitea.io/gitea/models/perm/access"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unit"
 	"code.gitea.io/gitea/modules/actions"
@@ -30,16 +28,13 @@ import (
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/storage"
-	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
 	actions_service "code.gitea.io/gitea/services/actions"
 	context_module "code.gitea.io/gitea/services/context"
-	"code.gitea.io/gitea/services/convert"
 
-	"github.com/nektos/act/pkg/jobparser"
 	"github.com/nektos/act/pkg/model"
 	"xorm.io/builder"
 )
@@ -281,86 +276,100 @@ func ViewPost(ctx *context_module.Context) {
 	resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json
 	resp.Logs.StepsLog = make([]*ViewStepLog, 0)          // marshal to '[]' instead fo 'null' in json
 	if task != nil {
-		steps := actions.FullSteps(task)
-
-		for _, v := range steps {
-			resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &ViewJobStep{
-				Summary:  v.Name,
-				Duration: v.Duration().String(),
-				Status:   v.Status.String(),
-			})
-		}
-
-		for _, cursor := range req.LogCursors {
-			if !cursor.Expanded {
-				continue
-			}
-
-			step := steps[cursor.Step]
-
-			// if task log is expired, return a consistent log line
-			if task.LogExpired {
-				if cursor.Cursor == 0 {
-					resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{
-						Step:   cursor.Step,
-						Cursor: 1,
-						Lines: []*ViewStepLogLine{
-							{
-								Index:   1,
-								Message: ctx.Locale.TrString("actions.runs.expire_log_message"),
-								// Timestamp doesn't mean anything when the log is expired.
-								// Set it to the task's updated time since it's probably the time when the log has expired.
-								Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second),
-							},
-						},
-						Started: int64(step.Started),
-					})
-				}
-				continue
-			}
-
-			logLines := make([]*ViewStepLogLine, 0) // marshal to '[]' instead fo 'null' in json
-
-			index := step.LogIndex + cursor.Cursor
-			validCursor := cursor.Cursor >= 0 &&
-				// !(cursor.Cursor < step.LogLength) when the frontend tries to fetch next line before it's ready.
-				// So return the same cursor and empty lines to let the frontend retry.
-				cursor.Cursor < step.LogLength &&
-				// !(index < task.LogIndexes[index]) when task data is older than step data.
-				// It can be fixed by making sure write/read tasks and steps in the same transaction,
-				// but it's easier to just treat it as fetching the next line before it's ready.
-				index < int64(len(task.LogIndexes))
-
-			if validCursor {
-				length := step.LogLength - cursor.Cursor
-				offset := task.LogIndexes[index]
-				logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length)
-				if err != nil {
-					ctx.ServerError("actions.ReadLogs", err)
-					return
-				}
-
-				for i, row := range logRows {
-					logLines = append(logLines, &ViewStepLogLine{
-						Index:     cursor.Cursor + int64(i) + 1, // start at 1
-						Message:   row.Content,
-						Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second),
-					})
-				}
-			}
-
-			resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{
-				Step:    cursor.Step,
-				Cursor:  cursor.Cursor + int64(len(logLines)),
-				Lines:   logLines,
-				Started: int64(step.Started),
-			})
+		steps, logs, err := convertToViewModel(ctx, req.LogCursors, task)
+		if err != nil {
+			ctx.Error(http.StatusInternalServerError, err.Error())
+			return
 		}
+		resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, steps...)
+		resp.Logs.StepsLog = append(resp.Logs.StepsLog, logs...)
 	}
 
 	ctx.JSON(http.StatusOK, resp)
 }
 
+func convertToViewModel(ctx *context_module.Context, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
+	var viewJobs []*ViewJobStep
+	var logs []*ViewStepLog
+
+	steps := actions.FullSteps(task)
+
+	for _, v := range steps {
+		viewJobs = append(viewJobs, &ViewJobStep{
+			Summary:  v.Name,
+			Duration: v.Duration().String(),
+			Status:   v.Status.String(),
+		})
+	}
+
+	for _, cursor := range cursors {
+		if !cursor.Expanded {
+			continue
+		}
+
+		step := steps[cursor.Step]
+
+		// if task log is expired, return a consistent log line
+		if task.LogExpired {
+			if cursor.Cursor == 0 {
+				logs = append(logs, &ViewStepLog{
+					Step:   cursor.Step,
+					Cursor: 1,
+					Lines: []*ViewStepLogLine{
+						{
+							Index:   1,
+							Message: ctx.Locale.TrString("actions.runs.expire_log_message"),
+							// Timestamp doesn't mean anything when the log is expired.
+							// Set it to the task's updated time since it's probably the time when the log has expired.
+							Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second),
+						},
+					},
+					Started: int64(step.Started),
+				})
+			}
+			continue
+		}
+
+		logLines := make([]*ViewStepLogLine, 0) // marshal to '[]' instead fo 'null' in json
+
+		index := step.LogIndex + cursor.Cursor
+		validCursor := cursor.Cursor >= 0 &&
+			// !(cursor.Cursor < step.LogLength) when the frontend tries to fetch next line before it's ready.
+			// So return the same cursor and empty lines to let the frontend retry.
+			cursor.Cursor < step.LogLength &&
+			// !(index < task.LogIndexes[index]) when task data is older than step data.
+			// It can be fixed by making sure write/read tasks and steps in the same transaction,
+			// but it's easier to just treat it as fetching the next line before it's ready.
+			index < int64(len(task.LogIndexes))
+
+		if validCursor {
+			length := step.LogLength - cursor.Cursor
+			offset := task.LogIndexes[index]
+			logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length)
+			if err != nil {
+				return nil, nil, fmt.Errorf("actions.ReadLogs: %w", err)
+			}
+
+			for i, row := range logRows {
+				logLines = append(logLines, &ViewStepLogLine{
+					Index:     cursor.Cursor + int64(i) + 1, // start at 1
+					Message:   row.Content,
+					Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second),
+				})
+			}
+		}
+
+		logs = append(logs, &ViewStepLog{
+			Step:    cursor.Step,
+			Cursor:  cursor.Cursor + int64(len(logLines)),
+			Lines:   logLines,
+			Started: int64(step.Started),
+		})
+	}
+
+	return viewJobs, logs, nil
+}
+
 // Rerun will rerun jobs in the given run
 // If jobIndexStr is a blank string, it means rerun all jobs
 func Rerun(ctx *context_module.Context) {
@@ -614,11 +623,6 @@ func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions
 }
 
 func ArtifactsDeleteView(ctx *context_module.Context) {
-	if !ctx.Repo.CanWrite(unit.TypeActions) {
-		ctx.Error(http.StatusForbidden, "no permission")
-		return
-	}
-
 	runIndex := getRunIndex(ctx)
 	artifactName := ctx.PathParam("artifact_name")
 
@@ -783,142 +787,28 @@ func Run(ctx *context_module.Context) {
 		ctx.ServerError("ref", nil)
 		return
 	}
-
-	// can not rerun job when workflow is disabled
-	cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
-	cfg := cfgUnit.ActionsConfig()
-	if cfg.IsWorkflowDisabled(workflowID) {
-		ctx.Flash.Error(ctx.Tr("actions.workflow.disabled"))
-		ctx.Redirect(redirectURL)
-		return
-	}
-
-	// get target commit of run from specified ref
-	refName := git.RefName(ref)
-	var runTargetCommit *git.Commit
-	var err error
-	if refName.IsTag() {
-		runTargetCommit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName())
-	} else if refName.IsBranch() {
-		runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName())
-	} else {
-		ctx.Flash.Error(ctx.Tr("form.git_ref_name_error", ref))
-		ctx.Redirect(redirectURL)
-		return
-	}
-	if err != nil {
-		ctx.Flash.Error(ctx.Tr("form.target_ref_not_exist", ref))
-		ctx.Redirect(redirectURL)
-		return
-	}
-
-	// get workflow entry from runTargetCommit
-	entries, err := actions.ListWorkflows(runTargetCommit)
-	if err != nil {
-		ctx.Error(http.StatusInternalServerError, err.Error())
-		return
-	}
-
-	// find workflow from commit
-	var workflows []*jobparser.SingleWorkflow
-	for _, entry := range entries {
-		if entry.Name() == workflowID {
-			content, err := actions.GetContentFromEntry(entry)
-			if err != nil {
-				ctx.Error(http.StatusInternalServerError, err.Error())
-				return
-			}
-			workflows, err = jobparser.Parse(content)
-			if err != nil {
-				ctx.ServerError("workflow", err)
-				return
-			}
-			break
-		}
-	}
-
-	if len(workflows) == 0 {
-		ctx.Flash.Error(ctx.Tr("actions.workflow.not_found", workflowID))
-		ctx.Redirect(redirectURL)
-		return
-	}
-
-	// get inputs from post
-	workflow := &model.Workflow{
-		RawOn: workflows[0].RawOn,
-	}
-	inputs := make(map[string]any)
-	if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil {
+	err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error {
 		for name, config := range workflowDispatch.Inputs {
 			value := ctx.Req.PostFormValue(name)
 			if config.Type == "boolean" {
-				// https://www.w3.org/TR/html401/interact/forms.html
-				// https://stackoverflow.com/questions/11424037/do-checkbox-inputs-only-post-data-if-theyre-checked
-				// Checkboxes (and radio buttons) are on/off switches that may be toggled by the user.
-				// A switch is "on" when the control element's checked attribute is set.
-				// When a form is submitted, only "on" checkbox controls can become successful.
-				inputs[name] = strconv.FormatBool(value == "on")
+				inputs[name] = strconv.FormatBool(ctx.FormBool(name))
 			} else if value != "" {
 				inputs[name] = value
 			} else {
 				inputs[name] = config.Default
 			}
 		}
-	}
-
-	// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
-	// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
-	// https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch
-	workflowDispatchPayload := &api.WorkflowDispatchPayload{
-		Workflow:   workflowID,
-		Ref:        ref,
-		Repository: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}),
-		Inputs:     inputs,
-		Sender:     convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone),
-	}
-	var eventPayload []byte
-	if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil {
-		ctx.ServerError("JSONPayload", err)
-		return
-	}
-
-	run := &actions_model.ActionRun{
-		Title:             strings.SplitN(runTargetCommit.CommitMessage, "\n", 2)[0],
-		RepoID:            ctx.Repo.Repository.ID,
-		OwnerID:           ctx.Repo.Repository.OwnerID,
-		WorkflowID:        workflowID,
-		TriggerUserID:     ctx.Doer.ID,
-		Ref:               ref,
-		CommitSHA:         runTargetCommit.ID.String(),
-		IsForkPullRequest: false,
-		Event:             "workflow_dispatch",
-		TriggerEvent:      "workflow_dispatch",
-		EventPayload:      string(eventPayload),
-		Status:            actions_model.StatusWaiting,
-	}
-
-	// cancel running jobs of the same workflow
-	if err := actions_model.CancelPreviousJobs(
-		ctx,
-		run.RepoID,
-		run.Ref,
-		run.WorkflowID,
-		run.Event,
-	); err != nil {
-		log.Error("CancelRunningJobs: %v", err)
-	}
-
-	// Insert the action run and its associated jobs into the database
-	if err := actions_model.InsertRun(ctx, run, workflows); err != nil {
-		ctx.ServerError("workflow", err)
-		return
-	}
-
-	alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
+		return nil
+	})
 	if err != nil {
-		log.Error("FindRunJobs: %v", err)
+		if errLocale := util.ErrAsLocale(err); errLocale != nil {
+			ctx.Flash.Error(ctx.Tr(errLocale.TrKey, errLocale.TrArgs...))
+			ctx.Redirect(redirectURL)
+		} else {
+			ctx.ServerError("DispatchActionWorkflow", err)
+		}
+		return
 	}
-	actions_service.CreateCommitStatus(ctx, alljobs...)
 
 	ctx.Flash.Success(ctx.Tr("actions.workflow.run_success", workflowID))
 	ctx.Redirect(redirectURL)
diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go
index 8ffda8ae0a..c8291d98c6 100644
--- a/routers/web/repo/commit.go
+++ b/routers/web/repo/commit.go
@@ -22,7 +22,6 @@ import (
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/charset"
 	"code.gitea.io/gitea/modules/git"
-	"code.gitea.io/gitea/modules/gitgraph"
 	"code.gitea.io/gitea/modules/gitrepo"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/markup"
@@ -32,6 +31,7 @@ import (
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/gitdiff"
 	repo_service "code.gitea.io/gitea/services/repository"
+	"code.gitea.io/gitea/services/repository/gitgraph"
 )
 
 const (
diff --git a/routers/web/repo/issue_suggestions.go b/routers/web/repo/issue_suggestions.go
index 46e9f339a5..9ef3942504 100644
--- a/routers/web/repo/issue_suggestions.go
+++ b/routers/web/repo/issue_suggestions.go
@@ -6,13 +6,10 @@ package repo
 import (
 	"net/http"
 
-	"code.gitea.io/gitea/models/db"
-	issues_model "code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/models/unit"
-	issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
 	"code.gitea.io/gitea/modules/optional"
-	"code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/services/context"
+	issue_service "code.gitea.io/gitea/services/issue"
 )
 
 // IssueSuggestions returns a list of issue suggestions
@@ -29,54 +26,11 @@ func IssueSuggestions(ctx *context.Context) {
 		isPull = optional.Some(false)
 	}
 
-	searchOpt := &issue_indexer.SearchOptions{
-		Paginator: &db.ListOptions{
-			Page:     0,
-			PageSize: 5,
-		},
-		Keyword:  keyword,
-		RepoIDs:  []int64{ctx.Repo.Repository.ID},
-		IsPull:   isPull,
-		IsClosed: nil,
-		SortBy:   issue_indexer.SortByUpdatedDesc,
-	}
-
-	ids, _, err := issue_indexer.SearchIssues(ctx, searchOpt)
+	suggestions, err := issue_service.GetSuggestion(ctx, ctx.Repo.Repository, isPull, keyword)
 	if err != nil {
-		ctx.ServerError("SearchIssues", err)
+		ctx.ServerError("GetSuggestion", err)
 		return
 	}
-	issues, err := issues_model.GetIssuesByIDs(ctx, ids, true)
-	if err != nil {
-		ctx.ServerError("FindIssuesByIDs", err)
-		return
-	}
-
-	suggestions := make([]*structs.Issue, 0, len(issues))
-
-	for _, issue := range issues {
-		suggestion := &structs.Issue{
-			ID:    issue.ID,
-			Index: issue.Index,
-			Title: issue.Title,
-			State: issue.State(),
-		}
-
-		if issue.IsPull {
-			if err := issue.LoadPullRequest(ctx); err != nil {
-				ctx.ServerError("LoadPullRequest", err)
-				return
-			}
-			if issue.PullRequest != nil {
-				suggestion.PullRequest = &structs.PullRequestMeta{
-					HasMerged:        issue.PullRequest.HasMerged,
-					IsWorkInProgress: issue.PullRequest.IsWorkInProgress(ctx),
-				}
-			}
-		}
-
-		suggestions = append(suggestions, suggestion)
-	}
 
 	ctx.JSON(http.StatusOK, suggestions)
 }
diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go
index aa49d2e1e8..aeb2fa52b6 100644
--- a/routers/web/repo/issue_view.go
+++ b/routers/web/repo/issue_view.go
@@ -4,7 +4,6 @@
 package repo
 
 import (
-	stdCtx "context"
 	"fmt"
 	"math/big"
 	"net/http"
@@ -40,86 +39,80 @@ import (
 )
 
 // roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and issue
-func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *user_model.User, permsCache map[int64]access_model.Permission, issue *issues_model.Issue, hasOriginalAuthor bool) (issues_model.RoleDescriptor, error) {
-	roleDescriptor := issues_model.RoleDescriptor{}
-
+func roleDescriptor(ctx *context.Context, repo *repo_model.Repository, poster *user_model.User, permsCache map[int64]access_model.Permission, issue *issues_model.Issue, hasOriginalAuthor bool) (roleDesc issues_model.RoleDescriptor, err error) {
 	if hasOriginalAuthor {
-		return roleDescriptor, nil
+		// the poster is a migrated user, so no need to detect the role
+		return roleDesc, nil
 	}
 
-	var perm access_model.Permission
-	var err error
-	if permsCache != nil {
-		var ok bool
-		perm, ok = permsCache[poster.ID]
-		if !ok {
-			perm, err = access_model.GetUserRepoPermission(ctx, repo, poster)
-			if err != nil {
-				return roleDescriptor, err
-			}
-		}
-		permsCache[poster.ID] = perm
-	} else {
+	if poster.IsGhost() || !poster.IsIndividual() {
+		return roleDesc, nil
+	}
+
+	roleDesc.IsPoster = issue.IsPoster(poster.ID) // check whether the comment's poster is the issue's poster
+
+	// Guess the role of the poster in the repo by permission
+	perm, hasPermCache := permsCache[poster.ID]
+	if !hasPermCache {
 		perm, err = access_model.GetUserRepoPermission(ctx, repo, poster)
 		if err != nil {
-			return roleDescriptor, err
+			return roleDesc, err
 		}
 	}
-
-	// If the poster is the actual poster of the issue, enable Poster role.
-	roleDescriptor.IsPoster = issue.IsPoster(poster.ID)
+	if permsCache != nil {
+		permsCache[poster.ID] = perm
+	}
 
 	// Check if the poster is owner of the repo.
 	if perm.IsOwner() {
-		// If the poster isn't an admin, enable the owner role.
+		// If the poster isn't a site admin, then is must be the repo's owner
 		if !poster.IsAdmin {
-			roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner
-			return roleDescriptor, nil
+			roleDesc.RoleInRepo = issues_model.RoleRepoOwner
+			return roleDesc, nil
 		}
-
-		// Otherwise check if poster is the real repo admin.
-		ok, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster)
+		// Otherwise (poster is site admin), check if poster is the real repo admin.
+		isRealRepoAdmin, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster)
 		if err != nil {
-			return roleDescriptor, err
+			return roleDesc, err
 		}
-		if ok {
-			roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner
-			return roleDescriptor, nil
+		if isRealRepoAdmin {
+			roleDesc.RoleInRepo = issues_model.RoleRepoOwner
+			return roleDesc, nil
 		}
 	}
 
 	// If repo is organization, check Member role
-	if err := repo.LoadOwner(ctx); err != nil {
-		return roleDescriptor, err
+	if err = repo.LoadOwner(ctx); err != nil {
+		return roleDesc, err
 	}
 	if repo.Owner.IsOrganization() {
 		if isMember, err := organization.IsOrganizationMember(ctx, repo.Owner.ID, poster.ID); err != nil {
-			return roleDescriptor, err
+			return roleDesc, err
 		} else if isMember {
-			roleDescriptor.RoleInRepo = issues_model.RoleRepoMember
-			return roleDescriptor, nil
+			roleDesc.RoleInRepo = issues_model.RoleRepoMember
+			return roleDesc, nil
 		}
 	}
 
 	// If the poster is the collaborator of the repo
 	if isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, poster.ID); err != nil {
-		return roleDescriptor, err
+		return roleDesc, err
 	} else if isCollaborator {
-		roleDescriptor.RoleInRepo = issues_model.RoleRepoCollaborator
-		return roleDescriptor, nil
+		roleDesc.RoleInRepo = issues_model.RoleRepoCollaborator
+		return roleDesc, nil
 	}
 
 	hasMergedPR, err := issues_model.HasMergedPullRequestInRepo(ctx, repo.ID, poster.ID)
 	if err != nil {
-		return roleDescriptor, err
+		return roleDesc, err
 	} else if hasMergedPR {
-		roleDescriptor.RoleInRepo = issues_model.RoleRepoContributor
+		roleDesc.RoleInRepo = issues_model.RoleRepoContributor
 	} else if issue.IsPull {
 		// only display first time contributor in the first opening pull request
-		roleDescriptor.RoleInRepo = issues_model.RoleRepoFirstTimeContributor
+		roleDesc.RoleInRepo = issues_model.RoleRepoFirstTimeContributor
 	}
 
-	return roleDescriptor, nil
+	return roleDesc, nil
 }
 
 func getBranchData(ctx *context.Context, issue *issues_model.Issue) {
diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go
index 8ebf5bcf39..0d4513ec67 100644
--- a/routers/web/repo/repo.go
+++ b/routers/web/repo/repo.go
@@ -304,31 +304,6 @@ func CreatePost(ctx *context.Context) {
 	handleCreateError(ctx, ctxUser, err, "CreatePost", tplCreate, &form)
 }
 
-const (
-	tplWatchUnwatch templates.TplName = "repo/watch_unwatch"
-	tplStarUnstar   templates.TplName = "repo/star_unstar"
-)
-
-func acceptTransfer(ctx *context.Context) {
-	err := repo_service.AcceptTransferOwnership(ctx, ctx.Repo.Repository, ctx.Doer)
-	if err == nil {
-		ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success"))
-		ctx.Redirect(ctx.Repo.Repository.Link())
-		return
-	}
-	handleActionError(ctx, err)
-}
-
-func rejectTransfer(ctx *context.Context) {
-	err := repo_service.RejectRepositoryTransfer(ctx, ctx.Repo.Repository, ctx.Doer)
-	if err == nil {
-		ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected"))
-		ctx.Redirect(ctx.Repo.Repository.Link())
-		return
-	}
-	handleActionError(ctx, err)
-}
-
 func handleActionError(ctx *context.Context, err error) {
 	if errors.Is(err, user_model.ErrBlockedUser) {
 		ctx.Flash.Error(ctx.Tr("repo.action.blocked_user"))
@@ -339,72 +314,6 @@ func handleActionError(ctx *context.Context, err error) {
 	}
 }
 
-// Action response for actions to a repository
-func Action(ctx *context.Context) {
-	var err error
-	switch ctx.PathParam("action") {
-	case "watch":
-		err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, true)
-	case "unwatch":
-		err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, false)
-	case "star":
-		err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, true)
-	case "unstar":
-		err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, false)
-	case "accept_transfer":
-		acceptTransfer(ctx)
-		return
-	case "reject_transfer":
-		rejectTransfer(ctx)
-		return
-	case "desc": // FIXME: this is not used
-		if !ctx.Repo.IsOwner() {
-			ctx.Error(http.StatusNotFound)
-			return
-		}
-
-		ctx.Repo.Repository.Description = ctx.FormString("desc")
-		ctx.Repo.Repository.Website = ctx.FormString("site")
-		err = repo_service.UpdateRepository(ctx, ctx.Repo.Repository, false)
-	}
-
-	if err != nil {
-		handleActionError(ctx, err)
-		return
-	}
-
-	switch ctx.PathParam("action") {
-	case "watch", "unwatch":
-		ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
-	case "star", "unstar":
-		ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
-	}
-
-	// see the `hx-trigger="refreshUserCards ..."` comments in tmpl
-	ctx.RespHeader().Add("hx-trigger", "refreshUserCards")
-
-	switch ctx.PathParam("action") {
-	case "watch", "unwatch", "star", "unstar":
-		// we have to reload the repository because NumStars or NumWatching (used in the templates) has just changed
-		ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name)
-		if err != nil {
-			ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.PathParam("action")), err)
-			return
-		}
-	}
-
-	switch ctx.PathParam("action") {
-	case "watch", "unwatch":
-		ctx.HTML(http.StatusOK, tplWatchUnwatch)
-		return
-	case "star", "unstar":
-		ctx.HTML(http.StatusOK, tplStarUnstar)
-		return
-	}
-
-	ctx.RedirectToCurrentSite(ctx.FormString("redirect_to"), ctx.Repo.RepoLink)
-}
-
 // RedirectDownload return a file based on the following infos:
 func RedirectDownload(ctx *context.Context) {
 	var (
diff --git a/routers/web/repo/setting/variables.go b/routers/web/repo/setting/variables.go
deleted file mode 100644
index 9b5453f043..0000000000
--- a/routers/web/repo/setting/variables.go
+++ /dev/null
@@ -1,140 +0,0 @@
-// Copyright 2023 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package setting
-
-import (
-	"errors"
-	"net/http"
-
-	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/templates"
-	shared "code.gitea.io/gitea/routers/web/shared/actions"
-	shared_user "code.gitea.io/gitea/routers/web/shared/user"
-	"code.gitea.io/gitea/services/context"
-)
-
-const (
-	tplRepoVariables  templates.TplName = "repo/settings/actions"
-	tplOrgVariables   templates.TplName = "org/settings/actions"
-	tplUserVariables  templates.TplName = "user/settings/actions"
-	tplAdminVariables templates.TplName = "admin/actions"
-)
-
-type variablesCtx struct {
-	OwnerID           int64
-	RepoID            int64
-	IsRepo            bool
-	IsOrg             bool
-	IsUser            bool
-	IsGlobal          bool
-	VariablesTemplate templates.TplName
-	RedirectLink      string
-}
-
-func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) {
-	if ctx.Data["PageIsRepoSettings"] == true {
-		return &variablesCtx{
-			OwnerID:           0,
-			RepoID:            ctx.Repo.Repository.ID,
-			IsRepo:            true,
-			VariablesTemplate: tplRepoVariables,
-			RedirectLink:      ctx.Repo.RepoLink + "/settings/actions/variables",
-		}, nil
-	}
-
-	if ctx.Data["PageIsOrgSettings"] == true {
-		err := shared_user.LoadHeaderCount(ctx)
-		if err != nil {
-			ctx.ServerError("LoadHeaderCount", err)
-			return nil, nil
-		}
-		return &variablesCtx{
-			OwnerID:           ctx.ContextUser.ID,
-			RepoID:            0,
-			IsOrg:             true,
-			VariablesTemplate: tplOrgVariables,
-			RedirectLink:      ctx.Org.OrgLink + "/settings/actions/variables",
-		}, nil
-	}
-
-	if ctx.Data["PageIsUserSettings"] == true {
-		return &variablesCtx{
-			OwnerID:           ctx.Doer.ID,
-			RepoID:            0,
-			IsUser:            true,
-			VariablesTemplate: tplUserVariables,
-			RedirectLink:      setting.AppSubURL + "/user/settings/actions/variables",
-		}, nil
-	}
-
-	if ctx.Data["PageIsAdmin"] == true {
-		return &variablesCtx{
-			OwnerID:           0,
-			RepoID:            0,
-			IsGlobal:          true,
-			VariablesTemplate: tplAdminVariables,
-			RedirectLink:      setting.AppSubURL + "/-/admin/actions/variables",
-		}, nil
-	}
-
-	return nil, errors.New("unable to set Variables context")
-}
-
-func Variables(ctx *context.Context) {
-	ctx.Data["Title"] = ctx.Tr("actions.variables")
-	ctx.Data["PageType"] = "variables"
-	ctx.Data["PageIsSharedSettingsVariables"] = true
-
-	vCtx, err := getVariablesCtx(ctx)
-	if err != nil {
-		ctx.ServerError("getVariablesCtx", err)
-		return
-	}
-
-	shared.SetVariablesContext(ctx, vCtx.OwnerID, vCtx.RepoID)
-	if ctx.Written() {
-		return
-	}
-
-	ctx.HTML(http.StatusOK, vCtx.VariablesTemplate)
-}
-
-func VariableCreate(ctx *context.Context) {
-	vCtx, err := getVariablesCtx(ctx)
-	if err != nil {
-		ctx.ServerError("getVariablesCtx", err)
-		return
-	}
-
-	if ctx.HasError() { // form binding validation error
-		ctx.JSONError(ctx.GetErrMsg())
-		return
-	}
-
-	shared.CreateVariable(ctx, vCtx.OwnerID, vCtx.RepoID, vCtx.RedirectLink)
-}
-
-func VariableUpdate(ctx *context.Context) {
-	vCtx, err := getVariablesCtx(ctx)
-	if err != nil {
-		ctx.ServerError("getVariablesCtx", err)
-		return
-	}
-
-	if ctx.HasError() { // form binding validation error
-		ctx.JSONError(ctx.GetErrMsg())
-		return
-	}
-
-	shared.UpdateVariable(ctx, vCtx.RedirectLink)
-}
-
-func VariableDelete(ctx *context.Context) {
-	vCtx, err := getVariablesCtx(ctx)
-	if err != nil {
-		ctx.ServerError("getVariablesCtx", err)
-		return
-	}
-	shared.DeleteVariable(ctx, vCtx.RedirectLink)
-}
diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go
index 997145b507..4ff2467041 100644
--- a/routers/web/repo/setting/webhook.go
+++ b/routers/web/repo/setting/webhook.go
@@ -184,6 +184,7 @@ func ParseHookEvent(form forms.WebhookForm) *webhook_module.HookEvent {
 			webhook_module.HookEventWiki:                     form.Wiki,
 			webhook_module.HookEventRepository:               form.Repository,
 			webhook_module.HookEventPackage:                  form.Package,
+			webhook_module.HookEventStatus:                   form.Status,
 		},
 		BranchFilter: form.BranchFilter,
 	}
diff --git a/routers/web/repo/star.go b/routers/web/repo/star.go
new file mode 100644
index 0000000000..00c06b7d02
--- /dev/null
+++ b/routers/web/repo/star.go
@@ -0,0 +1,31 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+	"net/http"
+
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/modules/templates"
+	"code.gitea.io/gitea/services/context"
+)
+
+const tplStarUnstar templates.TplName = "repo/star_unstar"
+
+func ActionStar(ctx *context.Context) {
+	err := repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, ctx.PathParam("action") == "star")
+	if err != nil {
+		handleActionError(ctx, err)
+		return
+	}
+
+	ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
+	ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name)
+	if err != nil {
+		ctx.ServerError("GetRepositoryByName", err)
+		return
+	}
+	ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl
+	ctx.HTML(http.StatusOK, tplStarUnstar)
+}
diff --git a/routers/web/repo/transfer.go b/routers/web/repo/transfer.go
new file mode 100644
index 0000000000..5553eee674
--- /dev/null
+++ b/routers/web/repo/transfer.go
@@ -0,0 +1,38 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+	"code.gitea.io/gitea/services/context"
+	repo_service "code.gitea.io/gitea/services/repository"
+)
+
+func acceptTransfer(ctx *context.Context) {
+	err := repo_service.AcceptTransferOwnership(ctx, ctx.Repo.Repository, ctx.Doer)
+	if err == nil {
+		ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success"))
+		ctx.Redirect(ctx.Repo.Repository.Link())
+		return
+	}
+	handleActionError(ctx, err)
+}
+
+func rejectTransfer(ctx *context.Context) {
+	err := repo_service.RejectRepositoryTransfer(ctx, ctx.Repo.Repository, ctx.Doer)
+	if err == nil {
+		ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected"))
+		ctx.Redirect(ctx.Repo.Repository.Link())
+		return
+	}
+	handleActionError(ctx, err)
+}
+
+func ActionTransfer(ctx *context.Context) {
+	switch ctx.PathParam("action") {
+	case "accept_transfer":
+		acceptTransfer(ctx)
+	case "reject_transfer":
+		rejectTransfer(ctx)
+	}
+}
diff --git a/routers/web/repo/watch.go b/routers/web/repo/watch.go
new file mode 100644
index 0000000000..70c548b8ce
--- /dev/null
+++ b/routers/web/repo/watch.go
@@ -0,0 +1,31 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+	"net/http"
+
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/modules/templates"
+	"code.gitea.io/gitea/services/context"
+)
+
+const tplWatchUnwatch templates.TplName = "repo/watch_unwatch"
+
+func ActionWatch(ctx *context.Context) {
+	err := repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, ctx.PathParam("action") == "watch")
+	if err != nil {
+		handleActionError(ctx, err)
+		return
+	}
+
+	ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
+	ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name)
+	if err != nil {
+		ctx.ServerError("GetRepositoryByName", err)
+		return
+	}
+	ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl
+	ctx.HTML(http.StatusOK, tplWatchUnwatch)
+}
diff --git a/routers/web/shared/actions/variables.go b/routers/web/shared/actions/variables.go
index f895475748..052a8fdd18 100644
--- a/routers/web/shared/actions/variables.go
+++ b/routers/web/shared/actions/variables.go
@@ -4,31 +4,127 @@
 package actions
 
 import (
+	"errors"
+	"net/http"
+
 	actions_model "code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/web"
+	shared_user "code.gitea.io/gitea/routers/web/shared/user"
 	actions_service "code.gitea.io/gitea/services/actions"
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/forms"
 )
 
-func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) {
+const (
+	tplRepoVariables  templates.TplName = "repo/settings/actions"
+	tplOrgVariables   templates.TplName = "org/settings/actions"
+	tplUserVariables  templates.TplName = "user/settings/actions"
+	tplAdminVariables templates.TplName = "admin/actions"
+)
+
+type variablesCtx struct {
+	OwnerID           int64
+	RepoID            int64
+	IsRepo            bool
+	IsOrg             bool
+	IsUser            bool
+	IsGlobal          bool
+	VariablesTemplate templates.TplName
+	RedirectLink      string
+}
+
+func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) {
+	if ctx.Data["PageIsRepoSettings"] == true {
+		return &variablesCtx{
+			OwnerID:           0,
+			RepoID:            ctx.Repo.Repository.ID,
+			IsRepo:            true,
+			VariablesTemplate: tplRepoVariables,
+			RedirectLink:      ctx.Repo.RepoLink + "/settings/actions/variables",
+		}, nil
+	}
+
+	if ctx.Data["PageIsOrgSettings"] == true {
+		err := shared_user.LoadHeaderCount(ctx)
+		if err != nil {
+			ctx.ServerError("LoadHeaderCount", err)
+			return nil, nil
+		}
+		return &variablesCtx{
+			OwnerID:           ctx.ContextUser.ID,
+			RepoID:            0,
+			IsOrg:             true,
+			VariablesTemplate: tplOrgVariables,
+			RedirectLink:      ctx.Org.OrgLink + "/settings/actions/variables",
+		}, nil
+	}
+
+	if ctx.Data["PageIsUserSettings"] == true {
+		return &variablesCtx{
+			OwnerID:           ctx.Doer.ID,
+			RepoID:            0,
+			IsUser:            true,
+			VariablesTemplate: tplUserVariables,
+			RedirectLink:      setting.AppSubURL + "/user/settings/actions/variables",
+		}, nil
+	}
+
+	if ctx.Data["PageIsAdmin"] == true {
+		return &variablesCtx{
+			OwnerID:           0,
+			RepoID:            0,
+			IsGlobal:          true,
+			VariablesTemplate: tplAdminVariables,
+			RedirectLink:      setting.AppSubURL + "/-/admin/actions/variables",
+		}, nil
+	}
+
+	return nil, errors.New("unable to set Variables context")
+}
+
+func Variables(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("actions.variables")
+	ctx.Data["PageType"] = "variables"
+	ctx.Data["PageIsSharedSettingsVariables"] = true
+
+	vCtx, err := getVariablesCtx(ctx)
+	if err != nil {
+		ctx.ServerError("getVariablesCtx", err)
+		return
+	}
+
 	variables, err := db.Find[actions_model.ActionVariable](ctx, actions_model.FindVariablesOpts{
-		OwnerID: ownerID,
-		RepoID:  repoID,
+		OwnerID: vCtx.OwnerID,
+		RepoID:  vCtx.RepoID,
 	})
 	if err != nil {
 		ctx.ServerError("FindVariables", err)
 		return
 	}
 	ctx.Data["Variables"] = variables
+
+	ctx.HTML(http.StatusOK, vCtx.VariablesTemplate)
 }
 
-func CreateVariable(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
+func VariableCreate(ctx *context.Context) {
+	vCtx, err := getVariablesCtx(ctx)
+	if err != nil {
+		ctx.ServerError("getVariablesCtx", err)
+		return
+	}
+
+	if ctx.HasError() { // form binding validation error
+		ctx.JSONError(ctx.GetErrMsg())
+		return
+	}
+
 	form := web.GetForm(ctx).(*forms.EditVariableForm)
 
-	v, err := actions_service.CreateVariable(ctx, ownerID, repoID, form.Name, form.Data)
+	v, err := actions_service.CreateVariable(ctx, vCtx.OwnerID, vCtx.RepoID, form.Name, form.Data)
 	if err != nil {
 		log.Error("CreateVariable: %v", err)
 		ctx.JSONError(ctx.Tr("actions.variables.creation.failed"))
@@ -36,30 +132,92 @@ func CreateVariable(ctx *context.Context, ownerID, repoID int64, redirectURL str
 	}
 
 	ctx.Flash.Success(ctx.Tr("actions.variables.creation.success", v.Name))
-	ctx.JSONRedirect(redirectURL)
+	ctx.JSONRedirect(vCtx.RedirectLink)
 }
 
-func UpdateVariable(ctx *context.Context, redirectURL string) {
-	id := ctx.PathParamInt64("variable_id")
-	form := web.GetForm(ctx).(*forms.EditVariableForm)
+func VariableUpdate(ctx *context.Context) {
+	vCtx, err := getVariablesCtx(ctx)
+	if err != nil {
+		ctx.ServerError("getVariablesCtx", err)
+		return
+	}
 
-	if ok, err := actions_service.UpdateVariable(ctx, id, form.Name, form.Data); err != nil || !ok {
+	if ctx.HasError() { // form binding validation error
+		ctx.JSONError(ctx.GetErrMsg())
+		return
+	}
+
+	id := ctx.PathParamInt64("variable_id")
+
+	variable := findActionsVariable(ctx, id, vCtx)
+	if ctx.Written() {
+		return
+	}
+
+	form := web.GetForm(ctx).(*forms.EditVariableForm)
+	variable.Name = form.Name
+	variable.Data = form.Data
+
+	if ok, err := actions_service.UpdateVariableNameData(ctx, variable); err != nil || !ok {
 		log.Error("UpdateVariable: %v", err)
 		ctx.JSONError(ctx.Tr("actions.variables.update.failed"))
 		return
 	}
 	ctx.Flash.Success(ctx.Tr("actions.variables.update.success"))
-	ctx.JSONRedirect(redirectURL)
+	ctx.JSONRedirect(vCtx.RedirectLink)
 }
 
-func DeleteVariable(ctx *context.Context, redirectURL string) {
+func findActionsVariable(ctx *context.Context, id int64, vCtx *variablesCtx) *actions_model.ActionVariable {
+	opts := actions_model.FindVariablesOpts{
+		IDs: []int64{id},
+	}
+	switch {
+	case vCtx.IsRepo:
+		opts.RepoID = vCtx.RepoID
+		if opts.RepoID == 0 {
+			panic("RepoID is 0")
+		}
+	case vCtx.IsOrg, vCtx.IsUser:
+		opts.OwnerID = vCtx.OwnerID
+		if opts.OwnerID == 0 {
+			panic("OwnerID is 0")
+		}
+	case vCtx.IsGlobal:
+		// do nothing
+	default:
+		panic("invalid actions variable")
+	}
+
+	got, err := actions_model.FindVariables(ctx, opts)
+	if err != nil {
+		ctx.ServerError("FindVariables", err)
+		return nil
+	} else if len(got) == 0 {
+		ctx.NotFound("FindVariables", nil)
+		return nil
+	}
+	return got[0]
+}
+
+func VariableDelete(ctx *context.Context) {
+	vCtx, err := getVariablesCtx(ctx)
+	if err != nil {
+		ctx.ServerError("getVariablesCtx", err)
+		return
+	}
+
 	id := ctx.PathParamInt64("variable_id")
 
-	if err := actions_service.DeleteVariableByID(ctx, id); err != nil {
+	variable := findActionsVariable(ctx, id, vCtx)
+	if ctx.Written() {
+		return
+	}
+
+	if err := actions_service.DeleteVariableByID(ctx, variable.ID); err != nil {
 		log.Error("Delete variable [%d] failed: %v", id, err)
 		ctx.JSONError(ctx.Tr("actions.variables.deletion.failed"))
 		return
 	}
 	ctx.Flash.Success(ctx.Tr("actions.variables.deletion.success"))
-	ctx.JSONRedirect(redirectURL)
+	ctx.JSONRedirect(vCtx.RedirectLink)
 }
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
index 006ffdcf7e..7cda3c038c 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -313,8 +313,8 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
 	ctx.Data["Page"] = pager
 }
 
-// Action response for follow/unfollow user request
-func Action(ctx *context.Context) {
+// ActionUserFollow is for follow/unfollow user request
+func ActionUserFollow(ctx *context.Context) {
 	var err error
 	switch ctx.FormString("action") {
 	case "follow":
@@ -339,6 +339,6 @@ func Action(ctx *context.Context) {
 		ctx.HTML(http.StatusOK, tplFollowUnfollow)
 		return
 	}
-	log.Error("Failed to apply action %q: unsupport context user type: %s", ctx.FormString("action"), ctx.ContextUser.Type)
+	log.Error("Failed to apply action %q: unsupported context user type: %s", ctx.FormString("action"), ctx.ContextUser.Type)
 	ctx.Error(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action")))
 }
diff --git a/routers/web/web.go b/routers/web/web.go
index bbf257a493..2745f7df41 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -37,6 +37,7 @@ import (
 	"code.gitea.io/gitea/routers/web/repo"
 	"code.gitea.io/gitea/routers/web/repo/actions"
 	repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
+	shared_actions "code.gitea.io/gitea/routers/web/shared/actions"
 	"code.gitea.io/gitea/routers/web/shared/project"
 	"code.gitea.io/gitea/routers/web/user"
 	user_setting "code.gitea.io/gitea/routers/web/user/setting"
@@ -118,7 +119,7 @@ func webAuth(authMethod auth_service.Method) func(*context.Context) {
 		ar, err := common.AuthShared(ctx.Base, ctx.Session, authMethod)
 		if err != nil {
 			log.Error("Failed to verify user: %v", err)
-			ctx.Error(http.StatusUnauthorized, "Verify")
+			ctx.Error(http.StatusUnauthorized, "Failed to authenticate user")
 			return
 		}
 		ctx.Doer = ar.Doer
@@ -347,6 +348,13 @@ func registerRoutes(m *web.Router) {
 		}
 	}
 
+	starsEnabled := func(ctx *context.Context) {
+		if setting.Repository.DisableStars {
+			ctx.Error(http.StatusForbidden)
+			return
+		}
+	}
+
 	lfsServerEnabled := func(ctx *context.Context) {
 		if !setting.LFS.StartServer {
 			ctx.Error(http.StatusNotFound)
@@ -442,10 +450,10 @@ func registerRoutes(m *web.Router) {
 
 	addSettingsVariablesRoutes := func() {
 		m.Group("/variables", func() {
-			m.Get("", repo_setting.Variables)
-			m.Post("/new", web.Bind(forms.EditVariableForm{}), repo_setting.VariableCreate)
-			m.Post("/{variable_id}/edit", web.Bind(forms.EditVariableForm{}), repo_setting.VariableUpdate)
-			m.Post("/{variable_id}/delete", repo_setting.VariableDelete)
+			m.Get("", shared_actions.Variables)
+			m.Post("/new", web.Bind(forms.EditVariableForm{}), shared_actions.VariableCreate)
+			m.Post("/{variable_id}/edit", web.Bind(forms.EditVariableForm{}), shared_actions.VariableUpdate)
+			m.Post("/{variable_id}/delete", shared_actions.VariableDelete)
 		})
 	}
 
@@ -815,7 +823,7 @@ func registerRoutes(m *web.Router) {
 		m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), repo.GetAttachment)
 	}, optSignIn)
 
-	m.Post("/{username}", reqSignIn, context.UserAssignmentWeb(), user.Action)
+	m.Post("/{username}", reqSignIn, context.UserAssignmentWeb(), user.ActionUserFollow)
 
 	reqRepoAdmin := context.RequireRepoAdmin()
 	reqRepoCodeWriter := context.RequireUnitWriter(unit.TypeCode)
@@ -865,7 +873,7 @@ func registerRoutes(m *web.Router) {
 	m.Group("/org", func() {
 		m.Group("/{org}", func() {
 			m.Get("/members", org.Members)
-		}, context.OrgAssignment())
+		}, context.OrgAssignment(context.OrgAssignmentOptions{}))
 	}, optSignIn)
 	// end "/org": members
 
@@ -891,19 +899,20 @@ func registerRoutes(m *web.Router) {
 			m.Get("/milestones/{team}", reqMilestonesDashboardPageEnabled, user.Milestones)
 			m.Post("/members/action/{action}", org.MembersAction)
 			m.Get("/teams", org.Teams)
-		}, context.OrgAssignment(true, false, true))
+		}, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true}))
 
 		m.Group("/{org}", func() {
 			m.Get("/teams/{team}", org.TeamMembers)
 			m.Get("/teams/{team}/repositories", org.TeamRepositories)
 			m.Post("/teams/{team}/action/{action}", org.TeamsAction)
 			m.Post("/teams/{team}/action/repo/{action}", org.TeamsRepoAction)
-		}, context.OrgAssignment(true, false, true))
+		}, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true}))
 
-		// require admin permission
+		// require member/team-admin permission (old logic is: requireMember=true, requireTeamAdmin=true)
+		// but it doesn't seem right: requireTeamAdmin does nothing
 		m.Group("/{org}", func() {
 			m.Get("/teams/-/search", org.SearchTeam)
-		}, context.OrgAssignment(true, false, false, true))
+		}, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamAdmin: true}))
 
 		// require owner permission
 		m.Group("/{org}", func() {
@@ -913,6 +922,8 @@ func registerRoutes(m *web.Router) {
 			m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost)
 			m.Post("/teams/{team}/delete", org.DeleteTeam)
 
+			m.Get("/worktime", context.OrgAssignment(context.OrgAssignmentOptions{RequireOwner: true}), org.Worktime)
+
 			m.Group("/settings", func() {
 				m.Combo("").Get(org.Settings).
 					Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost)
@@ -980,7 +991,7 @@ func registerRoutes(m *web.Router) {
 					m.Post("", web.Bind(forms.BlockUserForm{}), org.BlockedUsersPost)
 				})
 			}, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true))
-		}, context.OrgAssignment(true, true))
+		}, context.OrgAssignment(context.OrgAssignmentOptions{RequireOwner: true}))
 	}, reqSignIn)
 	// end "/org": most org routes
 
@@ -1050,7 +1061,7 @@ func registerRoutes(m *web.Router) {
 		m.Group("", func() {
 			m.Get("/code", user.CodeSearch)
 		}, reqUnitAccess(unit.TypeCode, perm.AccessModeRead, false), individualPermsChecker)
-	}, optSignIn, context.UserAssignmentWeb(), context.OrgAssignment())
+	}, optSignIn, context.UserAssignmentWeb(), context.OrgAssignment(context.OrgAssignmentOptions{}))
 	// end "/{username}/-": packages, projects, code
 
 	m.Group("/{username}/{reponame}/-", func() {
@@ -1428,7 +1439,7 @@ func registerRoutes(m *web.Router) {
 			m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
 			m.Post("/approve", reqRepoActionsWriter, actions.Approve)
 			m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
-			m.Delete("/artifacts/{artifact_name}", actions.ArtifactsDeleteView)
+			m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView)
 			m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
 		})
 		m.Group("/workflows/{workflow_name}", func() {
@@ -1591,10 +1602,12 @@ func registerRoutes(m *web.Router) {
 	// end "/{username}/{reponame}": repo code
 
 	m.Group("/{username}/{reponame}", func() {
-		m.Get("/stars", repo.Stars)
+		m.Get("/stars", starsEnabled, repo.Stars)
 		m.Get("/watchers", repo.Watchers)
 		m.Get("/search", reqUnitCodeReader, repo.Search)
-		m.Post("/action/{action}", reqSignIn, repo.Action)
+		m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar)
+		m.Post("/action/{action:watch|unwatch}", reqSignIn, repo.ActionWatch)
+		m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer)
 	}, optSignIn, context.RepoAssignment)
 
 	common.AddOwnerRepoGitLFSRoutes(m, optSignInIgnoreCsrf, lfsServerEnabled) // "/{username}/{reponame}/{lfs-paths}": git-lfs support
diff --git a/services/actions/cleanup.go b/services/actions/cleanup.go
index 1223ebcab6..ee1d167713 100644
--- a/services/actions/cleanup.go
+++ b/services/actions/cleanup.go
@@ -52,9 +52,9 @@ func cleanExpiredArtifacts(taskCtx context.Context) error {
 		}
 		if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil {
 			log.Error("Cannot delete artifact %d: %v", artifact.ID, err)
-			continue
+			// go on
 		}
-		log.Info("Artifact %d set expired", artifact.ID)
+		log.Info("Artifact %d is deleted (due to expiration)", artifact.ID)
 	}
 	return nil
 }
@@ -76,9 +76,9 @@ func cleanNeedDeleteArtifacts(taskCtx context.Context) error {
 			}
 			if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil {
 				log.Error("Cannot delete artifact %d: %v", artifact.ID, err)
-				continue
+				// go on
 			}
-			log.Info("Artifact %d set deleted", artifact.ID)
+			log.Info("Artifact %d is deleted (due to pending deletion)", artifact.ID)
 		}
 		if len(artifacts) < deleteArtifactBatchSize {
 			log.Debug("No more artifacts pending deletion")
@@ -103,8 +103,7 @@ func CleanupLogs(ctx context.Context) error {
 		for _, task := range tasks {
 			if err := actions_module.RemoveLogs(ctx, task.LogInStorage, task.LogFilename); err != nil {
 				log.Error("Failed to remove log %s (in storage %v) of task %v: %v", task.LogFilename, task.LogInStorage, task.ID, err)
-				// do not return error here, continue to next task
-				continue
+				// do not return error here, go on
 			}
 			task.LogIndexes = nil // clear log indexes since it's a heavy field
 			task.LogExpired = true
diff --git a/services/actions/variables.go b/services/actions/variables.go
index 8dde9c4af5..95f088dbd3 100644
--- a/services/actions/variables.go
+++ b/services/actions/variables.go
@@ -6,7 +6,6 @@ package actions
 import (
 	"context"
 	"regexp"
-	"strings"
 
 	actions_model "code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/modules/log"
@@ -31,20 +30,18 @@ func CreateVariable(ctx context.Context, ownerID, repoID int64, name, data strin
 	return v, nil
 }
 
-func UpdateVariable(ctx context.Context, variableID int64, name, data string) (bool, error) {
-	if err := secret_service.ValidateName(name); err != nil {
+func UpdateVariableNameData(ctx context.Context, variable *actions_model.ActionVariable) (bool, error) {
+	if err := secret_service.ValidateName(variable.Name); err != nil {
 		return false, err
 	}
 
-	if err := envNameCIRegexMatch(name); err != nil {
+	if err := envNameCIRegexMatch(variable.Name); err != nil {
 		return false, err
 	}
 
-	return actions_model.UpdateVariable(ctx, &actions_model.ActionVariable{
-		ID:   variableID,
-		Name: strings.ToUpper(name),
-		Data: util.ReserveLineBreakForTextarea(data),
-	})
+	variable.Data = util.ReserveLineBreakForTextarea(variable.Data)
+
+	return actions_model.UpdateVariableCols(ctx, variable, "name", "data")
 }
 
 func DeleteVariableByID(ctx context.Context, variableID int64) error {
diff --git a/services/actions/workflow.go b/services/actions/workflow.go
new file mode 100644
index 0000000000..4470b60c64
--- /dev/null
+++ b/services/actions/workflow.go
@@ -0,0 +1,281 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"path"
+	"strings"
+
+	actions_model "code.gitea.io/gitea/models/actions"
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/perm"
+	access_model "code.gitea.io/gitea/models/perm/access"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unit"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/actions"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/reqctx"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/convert"
+
+	"github.com/nektos/act/pkg/jobparser"
+	"github.com/nektos/act/pkg/model"
+)
+
+func getActionWorkflowPath(commit *git.Commit) string {
+	paths := []string{".gitea/workflows", ".github/workflows"}
+	for _, treePath := range paths {
+		if _, err := commit.SubTree(treePath); err == nil {
+			return treePath
+		}
+	}
+	return ""
+}
+
+func getActionWorkflowEntry(ctx *context.APIContext, commit *git.Commit, folder string, entry *git.TreeEntry) *api.ActionWorkflow {
+	cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
+	cfg := cfgUnit.ActionsConfig()
+
+	defaultBranch, _ := commit.GetBranchName()
+
+	workflowURL := fmt.Sprintf("%s/actions/workflows/%s", ctx.Repo.Repository.APIURL(), url.PathEscape(entry.Name()))
+	workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", ctx.Repo.Repository.HTMLURL(ctx), util.PathEscapeSegments(defaultBranch), util.PathEscapeSegments(folder), url.PathEscape(entry.Name()))
+	badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", ctx.Repo.Repository.HTMLURL(ctx), url.PathEscape(entry.Name()), url.QueryEscape(ctx.Repo.Repository.DefaultBranch))
+
+	// See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow
+	// State types:
+	// - active
+	// - deleted
+	// - disabled_fork
+	// - disabled_inactivity
+	// - disabled_manually
+	state := "active"
+	if cfg.IsWorkflowDisabled(entry.Name()) {
+		state = "disabled_manually"
+	}
+
+	// The CreatedAt and UpdatedAt fields currently reflect the timestamp of the latest commit, which can later be refined
+	// by retrieving the first and last commits for the file history. The first commit would indicate the creation date,
+	// while the last commit would represent the modification date. The DeletedAt could be determined by identifying
+	// the last commit where the file existed. However, this implementation has not been done here yet, as it would likely
+	// cause a significant performance degradation.
+	createdAt := commit.Author.When
+	updatedAt := commit.Author.When
+
+	return &api.ActionWorkflow{
+		ID:        entry.Name(),
+		Name:      entry.Name(),
+		Path:      path.Join(folder, entry.Name()),
+		State:     state,
+		CreatedAt: createdAt,
+		UpdatedAt: updatedAt,
+		URL:       workflowURL,
+		HTMLURL:   workflowRepoURL,
+		BadgeURL:  badgeURL,
+	}
+}
+
+func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
+	workflow, err := GetActionWorkflow(ctx, workflowID)
+	if err != nil {
+		return err
+	}
+
+	cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
+	cfg := cfgUnit.ActionsConfig()
+
+	if isEnable {
+		cfg.EnableWorkflow(workflow.ID)
+	} else {
+		cfg.DisableWorkflow(workflow.ID)
+	}
+
+	return repo_model.UpdateRepoUnit(ctx, cfgUnit)
+}
+
+func ListActionWorkflows(ctx *context.APIContext) ([]*api.ActionWorkflow, error) {
+	defaultBranchCommit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, "WorkflowDefaultBranchError", err.Error())
+		return nil, err
+	}
+
+	entries, err := actions.ListWorkflows(defaultBranchCommit)
+	if err != nil {
+		ctx.Error(http.StatusNotFound, "WorkflowListNotFound", err.Error())
+		return nil, err
+	}
+
+	folder := getActionWorkflowPath(defaultBranchCommit)
+
+	workflows := make([]*api.ActionWorkflow, len(entries))
+	for i, entry := range entries {
+		workflows[i] = getActionWorkflowEntry(ctx, defaultBranchCommit, folder, entry)
+	}
+
+	return workflows, nil
+}
+
+func GetActionWorkflow(ctx *context.APIContext, workflowID string) (*api.ActionWorkflow, error) {
+	entries, err := ListActionWorkflows(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, entry := range entries {
+		if entry.Name == workflowID {
+			return entry, nil
+		}
+	}
+
+	return nil, util.NewNotExistErrorf("workflow %q not found", workflowID)
+}
+
+func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) error {
+	if workflowID == "" {
+		return util.ErrWrapLocale(
+			util.NewNotExistErrorf("workflowID is empty"),
+			"actions.workflow.not_found", workflowID,
+		)
+	}
+
+	if ref == "" {
+		return util.ErrWrapLocale(
+			util.NewNotExistErrorf("ref is empty"),
+			"form.target_ref_not_exist", ref,
+		)
+	}
+
+	// can not rerun job when workflow is disabled
+	cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
+	cfg := cfgUnit.ActionsConfig()
+	if cfg.IsWorkflowDisabled(workflowID) {
+		return util.ErrWrapLocale(
+			util.NewPermissionDeniedErrorf("workflow is disabled"),
+			"actions.workflow.disabled",
+		)
+	}
+
+	// get target commit of run from specified ref
+	refName := git.RefName(ref)
+	var runTargetCommit *git.Commit
+	var err error
+	if refName.IsTag() {
+		runTargetCommit, err = gitRepo.GetTagCommit(refName.TagName())
+	} else if refName.IsBranch() {
+		runTargetCommit, err = gitRepo.GetBranchCommit(refName.BranchName())
+	} else {
+		refName = git.RefNameFromBranch(ref)
+		runTargetCommit, err = gitRepo.GetBranchCommit(ref)
+	}
+	if err != nil {
+		return util.ErrWrapLocale(
+			util.NewNotExistErrorf("ref %q doesn't exist", ref),
+			"form.target_ref_not_exist", ref,
+		)
+	}
+
+	// get workflow entry from runTargetCommit
+	entries, err := actions.ListWorkflows(runTargetCommit)
+	if err != nil {
+		return err
+	}
+
+	// find workflow from commit
+	var workflows []*jobparser.SingleWorkflow
+	for _, entry := range entries {
+		if entry.Name() != workflowID {
+			continue
+		}
+
+		content, err := actions.GetContentFromEntry(entry)
+		if err != nil {
+			return err
+		}
+		workflows, err = jobparser.Parse(content)
+		if err != nil {
+			return err
+		}
+		break
+	}
+
+	if len(workflows) == 0 {
+		return util.ErrWrapLocale(
+			util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
+			"actions.workflow.not_found", workflowID,
+		)
+	}
+
+	// get inputs from post
+	workflow := &model.Workflow{
+		RawOn: workflows[0].RawOn,
+	}
+	inputsWithDefaults := make(map[string]any)
+	if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil {
+		if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
+			return err
+		}
+	}
+
+	// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
+	// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
+	// https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch
+	workflowDispatchPayload := &api.WorkflowDispatchPayload{
+		Workflow:   workflowID,
+		Ref:        ref,
+		Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
+		Inputs:     inputsWithDefaults,
+		Sender:     convert.ToUserWithAccessMode(ctx, doer, perm.AccessModeNone),
+	}
+	var eventPayload []byte
+	if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil {
+		return fmt.Errorf("JSONPayload: %w", err)
+	}
+
+	run := &actions_model.ActionRun{
+		Title:             strings.SplitN(runTargetCommit.CommitMessage, "\n", 2)[0],
+		RepoID:            repo.ID,
+		OwnerID:           repo.OwnerID,
+		WorkflowID:        workflowID,
+		TriggerUserID:     doer.ID,
+		Ref:               string(refName),
+		CommitSHA:         runTargetCommit.ID.String(),
+		IsForkPullRequest: false,
+		Event:             "workflow_dispatch",
+		TriggerEvent:      "workflow_dispatch",
+		EventPayload:      string(eventPayload),
+		Status:            actions_model.StatusWaiting,
+	}
+
+	// cancel running jobs of the same workflow
+	if err := actions_model.CancelPreviousJobs(
+		ctx,
+		run.RepoID,
+		run.Ref,
+		run.WorkflowID,
+		run.Event,
+	); err != nil {
+		log.Error("CancelRunningJobs: %v", err)
+	}
+
+	// Insert the action run and its associated jobs into the database
+	if err := actions_model.InsertRun(ctx, run, workflows); err != nil {
+		return fmt.Errorf("InsertRun: %w", err)
+	}
+
+	allJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
+	if err != nil {
+		log.Error("FindRunJobs: %v", err)
+	}
+	CreateCommitStatus(ctx, allJobs...)
+
+	return nil
+}
diff --git a/services/context/api.go b/services/context/api.go
index bdeff0af63..baf4131edc 100644
--- a/services/context/api.go
+++ b/services/context/api.go
@@ -22,6 +22,9 @@ import (
 )
 
 // APIContext is a specific context for API service
+// ATTENTION: This struct should never be manually constructed in routes/services,
+// it has many internal details which should be carefully prepared by the framework.
+// If it is abused, it would cause strange bugs like panic/resource-leak.
 type APIContext struct {
 	*Base
 
diff --git a/services/context/base.go b/services/context/base.go
index 5db84f42a5..4d1c3659a2 100644
--- a/services/context/base.go
+++ b/services/context/base.go
@@ -23,6 +23,10 @@ type BaseContextKeyType struct{}
 
 var BaseContextKey BaseContextKeyType
 
+// Base is the base context for all web handlers
+// ATTENTION: This struct should never be manually constructed in routes/services,
+// it has many internal details which should be carefully prepared by the framework.
+// If it is abused, it would cause strange bugs like panic/resource-leak.
 type Base struct {
 	reqctx.RequestContext
 
diff --git a/services/context/context.go b/services/context/context.go
index 5b16f9be98..7aeb0de7ab 100644
--- a/services/context/context.go
+++ b/services/context/context.go
@@ -34,7 +34,10 @@ type Render interface {
 	HTML(w io.Writer, status int, name templates.TplName, data any, templateCtx context.Context) error
 }
 
-// Context represents context of a request.
+// Context represents context of a web request.
+// ATTENTION: This struct should never be manually constructed in routes/services,
+// it has many internal details which should be carefully prepared by the framework.
+// If it is abused, it would cause strange bugs like panic/resource-leak.
 type Context struct {
 	*Base
 
diff --git a/services/context/org.go b/services/context/org.go
index be87cef7a3..3f73165076 100644
--- a/services/context/org.go
+++ b/services/context/org.go
@@ -62,215 +62,193 @@ func GetOrganizationByParams(ctx *Context) {
 	}
 }
 
-// HandleOrgAssignment handles organization assignment
-func HandleOrgAssignment(ctx *Context, args ...bool) {
-	var (
-		requireMember     bool
-		requireOwner      bool
-		requireTeamMember bool
-		requireTeamAdmin  bool
-	)
-	if len(args) >= 1 {
-		requireMember = args[0]
-	}
-	if len(args) >= 2 {
-		requireOwner = args[1]
-	}
-	if len(args) >= 3 {
-		requireTeamMember = args[2]
-	}
-	if len(args) >= 4 {
-		requireTeamAdmin = args[3]
-	}
+type OrgAssignmentOptions struct {
+	RequireMember     bool
+	RequireOwner      bool
+	RequireTeamMember bool
+	RequireTeamAdmin  bool
+}
 
-	var err error
-
-	if ctx.ContextUser == nil {
-		// if Organization is not defined, get it from params
-		if ctx.Org.Organization == nil {
-			GetOrganizationByParams(ctx)
-			if ctx.Written() {
-				return
+// OrgAssignment returns a middleware to handle organization assignment
+func OrgAssignment(opts OrgAssignmentOptions) func(ctx *Context) {
+	return func(ctx *Context) {
+		var err error
+		if ctx.ContextUser == nil {
+			// if Organization is not defined, get it from params
+			if ctx.Org.Organization == nil {
+				GetOrganizationByParams(ctx)
+				if ctx.Written() {
+					return
+				}
 			}
-		}
-	} else if ctx.ContextUser.IsOrganization() {
-		if ctx.Org == nil {
-			ctx.Org = &Organization{}
-		}
-		ctx.Org.Organization = (*organization.Organization)(ctx.ContextUser)
-	} else {
-		// ContextUser is an individual User
-		return
-	}
-
-	org := ctx.Org.Organization
-
-	// Handle Visibility
-	if org.Visibility != structs.VisibleTypePublic && !ctx.IsSigned {
-		// We must be signed in to see limited or private organizations
-		ctx.NotFound("OrgAssignment", err)
-		return
-	}
-
-	if org.Visibility == structs.VisibleTypePrivate {
-		requireMember = true
-	} else if ctx.IsSigned && ctx.Doer.IsRestricted {
-		requireMember = true
-	}
-
-	ctx.ContextUser = org.AsUser()
-	ctx.Data["Org"] = org
-
-	// Admin has super access.
-	if ctx.IsSigned && ctx.Doer.IsAdmin {
-		ctx.Org.IsOwner = true
-		ctx.Org.IsMember = true
-		ctx.Org.IsTeamMember = true
-		ctx.Org.IsTeamAdmin = true
-		ctx.Org.CanCreateOrgRepo = true
-	} else if ctx.IsSigned {
-		ctx.Org.IsOwner, err = org.IsOwnedBy(ctx, ctx.Doer.ID)
-		if err != nil {
-			ctx.ServerError("IsOwnedBy", err)
+		} else if ctx.ContextUser.IsOrganization() {
+			ctx.Org.Organization = (*organization.Organization)(ctx.ContextUser)
+		} else {
+			// ContextUser is an individual User
 			return
 		}
 
-		if ctx.Org.IsOwner {
+		org := ctx.Org.Organization
+
+		// Handle Visibility
+		if org.Visibility != structs.VisibleTypePublic && !ctx.IsSigned {
+			// We must be signed in to see limited or private organizations
+			ctx.NotFound("OrgAssignment", err)
+			return
+		}
+
+		if org.Visibility == structs.VisibleTypePrivate {
+			opts.RequireMember = true
+		} else if ctx.IsSigned && ctx.Doer.IsRestricted {
+			opts.RequireMember = true
+		}
+
+		ctx.ContextUser = org.AsUser()
+		ctx.Data["Org"] = org
+
+		// Admin has super access.
+		if ctx.IsSigned && ctx.Doer.IsAdmin {
+			ctx.Org.IsOwner = true
 			ctx.Org.IsMember = true
 			ctx.Org.IsTeamMember = true
 			ctx.Org.IsTeamAdmin = true
 			ctx.Org.CanCreateOrgRepo = true
+		} else if ctx.IsSigned {
+			ctx.Org.IsOwner, err = org.IsOwnedBy(ctx, ctx.Doer.ID)
+			if err != nil {
+				ctx.ServerError("IsOwnedBy", err)
+				return
+			}
+
+			if ctx.Org.IsOwner {
+				ctx.Org.IsMember = true
+				ctx.Org.IsTeamMember = true
+				ctx.Org.IsTeamAdmin = true
+				ctx.Org.CanCreateOrgRepo = true
+			} else {
+				ctx.Org.IsMember, err = org.IsOrgMember(ctx, ctx.Doer.ID)
+				if err != nil {
+					ctx.ServerError("IsOrgMember", err)
+					return
+				}
+				ctx.Org.CanCreateOrgRepo, err = org.CanCreateOrgRepo(ctx, ctx.Doer.ID)
+				if err != nil {
+					ctx.ServerError("CanCreateOrgRepo", err)
+					return
+				}
+			}
 		} else {
-			ctx.Org.IsMember, err = org.IsOrgMember(ctx, ctx.Doer.ID)
-			if err != nil {
-				ctx.ServerError("IsOrgMember", err)
-				return
-			}
-			ctx.Org.CanCreateOrgRepo, err = org.CanCreateOrgRepo(ctx, ctx.Doer.ID)
-			if err != nil {
-				ctx.ServerError("CanCreateOrgRepo", err)
-				return
-			}
+			// Fake data.
+			ctx.Data["SignedUser"] = &user_model.User{}
 		}
-	} else {
-		// Fake data.
-		ctx.Data["SignedUser"] = &user_model.User{}
-	}
-	if (requireMember && !ctx.Org.IsMember) ||
-		(requireOwner && !ctx.Org.IsOwner) {
-		ctx.NotFound("OrgAssignment", err)
-		return
-	}
-	ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner
-	ctx.Data["IsOrganizationMember"] = ctx.Org.IsMember
-	ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
-	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
-	ctx.Data["IsPublicMember"] = func(uid int64) bool {
-		is, _ := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, uid)
-		return is
-	}
-	ctx.Data["CanCreateOrgRepo"] = ctx.Org.CanCreateOrgRepo
+		if (opts.RequireMember && !ctx.Org.IsMember) || (opts.RequireOwner && !ctx.Org.IsOwner) {
+			ctx.NotFound("OrgAssignment", err)
+			return
+		}
+		ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner
+		ctx.Data["IsOrganizationMember"] = ctx.Org.IsMember
+		ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
+		ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
+		ctx.Data["IsPublicMember"] = func(uid int64) bool {
+			is, _ := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, uid)
+			return is
+		}
+		ctx.Data["CanCreateOrgRepo"] = ctx.Org.CanCreateOrgRepo
 
-	ctx.Org.OrgLink = org.AsUser().OrganisationLink()
-	ctx.Data["OrgLink"] = ctx.Org.OrgLink
+		ctx.Org.OrgLink = org.AsUser().OrganisationLink()
+		ctx.Data["OrgLink"] = ctx.Org.OrgLink
 
-	// Member
-	opts := &organization.FindOrgMembersOpts{
-		Doer:         ctx.Doer,
-		OrgID:        org.ID,
-		IsDoerMember: ctx.Org.IsMember,
-	}
-	ctx.Data["NumMembers"], err = organization.CountOrgMembers(ctx, opts)
-	if err != nil {
-		ctx.ServerError("CountOrgMembers", err)
-		return
-	}
+		// Member
+		findMembersOpts := &organization.FindOrgMembersOpts{
+			Doer:         ctx.Doer,
+			OrgID:        org.ID,
+			IsDoerMember: ctx.Org.IsMember,
+		}
+		ctx.Data["NumMembers"], err = organization.CountOrgMembers(ctx, findMembersOpts)
+		if err != nil {
+			ctx.ServerError("CountOrgMembers", err)
+			return
+		}
 
-	// Team.
-	if ctx.Org.IsMember {
-		shouldSeeAllTeams := false
-		if ctx.Org.IsOwner {
-			shouldSeeAllTeams = true
-		} else {
-			teams, err := org.GetUserTeams(ctx, ctx.Doer.ID)
-			if err != nil {
-				ctx.ServerError("GetUserTeams", err)
-				return
+		// Team.
+		if ctx.Org.IsMember {
+			shouldSeeAllTeams := false
+			if ctx.Org.IsOwner {
+				shouldSeeAllTeams = true
+			} else {
+				teams, err := org.GetUserTeams(ctx, ctx.Doer.ID)
+				if err != nil {
+					ctx.ServerError("GetUserTeams", err)
+					return
+				}
+				for _, team := range teams {
+					if team.IncludesAllRepositories && team.AccessMode >= perm.AccessModeAdmin {
+						shouldSeeAllTeams = true
+						break
+					}
+				}
 			}
-			for _, team := range teams {
-				if team.IncludesAllRepositories && team.AccessMode >= perm.AccessModeAdmin {
-					shouldSeeAllTeams = true
+			if shouldSeeAllTeams {
+				ctx.Org.Teams, err = org.LoadTeams(ctx)
+				if err != nil {
+					ctx.ServerError("LoadTeams", err)
+					return
+				}
+			} else {
+				ctx.Org.Teams, err = org.GetUserTeams(ctx, ctx.Doer.ID)
+				if err != nil {
+					ctx.ServerError("GetUserTeams", err)
+					return
+				}
+			}
+			ctx.Data["NumTeams"] = len(ctx.Org.Teams)
+		}
+
+		teamName := ctx.PathParam("team")
+		if len(teamName) > 0 {
+			teamExists := false
+			for _, team := range ctx.Org.Teams {
+				if team.LowerName == strings.ToLower(teamName) {
+					teamExists = true
+					ctx.Org.Team = team
+					ctx.Org.IsTeamMember = true
+					ctx.Data["Team"] = ctx.Org.Team
 					break
 				}
 			}
-		}
-		if shouldSeeAllTeams {
-			ctx.Org.Teams, err = org.LoadTeams(ctx)
-			if err != nil {
-				ctx.ServerError("LoadTeams", err)
+
+			if !teamExists {
+				ctx.NotFound("OrgAssignment", err)
 				return
 			}
-		} else {
-			ctx.Org.Teams, err = org.GetUserTeams(ctx, ctx.Doer.ID)
-			if err != nil {
-				ctx.ServerError("GetUserTeams", err)
+
+			ctx.Data["IsTeamMember"] = ctx.Org.IsTeamMember
+			if opts.RequireTeamMember && !ctx.Org.IsTeamMember {
+				ctx.NotFound("OrgAssignment", err)
+				return
+			}
+
+			ctx.Org.IsTeamAdmin = ctx.Org.Team.IsOwnerTeam() || ctx.Org.Team.AccessMode >= perm.AccessModeAdmin
+			ctx.Data["IsTeamAdmin"] = ctx.Org.IsTeamAdmin
+			if opts.RequireTeamAdmin && !ctx.Org.IsTeamAdmin {
+				ctx.NotFound("OrgAssignment", err)
 				return
 			}
 		}
-		ctx.Data["NumTeams"] = len(ctx.Org.Teams)
-	}
+		ctx.Data["ContextUser"] = ctx.ContextUser
 
-	teamName := ctx.PathParam("team")
-	if len(teamName) > 0 {
-		teamExists := false
-		for _, team := range ctx.Org.Teams {
-			if team.LowerName == strings.ToLower(teamName) {
-				teamExists = true
-				ctx.Org.Team = team
-				ctx.Org.IsTeamMember = true
-				ctx.Data["Team"] = ctx.Org.Team
-				break
+		ctx.Data["CanReadProjects"] = ctx.Org.CanReadUnit(ctx, unit.TypeProjects)
+		ctx.Data["CanReadPackages"] = ctx.Org.CanReadUnit(ctx, unit.TypePackages)
+		ctx.Data["CanReadCode"] = ctx.Org.CanReadUnit(ctx, unit.TypeCode)
+
+		ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
+		if len(ctx.ContextUser.Description) != 0 {
+			content, err := markdown.RenderString(markup.NewRenderContext(ctx), ctx.ContextUser.Description)
+			if err != nil {
+				ctx.ServerError("RenderString", err)
+				return
 			}
+			ctx.Data["RenderedDescription"] = content
 		}
-
-		if !teamExists {
-			ctx.NotFound("OrgAssignment", err)
-			return
-		}
-
-		ctx.Data["IsTeamMember"] = ctx.Org.IsTeamMember
-		if requireTeamMember && !ctx.Org.IsTeamMember {
-			ctx.NotFound("OrgAssignment", err)
-			return
-		}
-
-		ctx.Org.IsTeamAdmin = ctx.Org.Team.IsOwnerTeam() || ctx.Org.Team.AccessMode >= perm.AccessModeAdmin
-		ctx.Data["IsTeamAdmin"] = ctx.Org.IsTeamAdmin
-		if requireTeamAdmin && !ctx.Org.IsTeamAdmin {
-			ctx.NotFound("OrgAssignment", err)
-			return
-		}
-	}
-	ctx.Data["ContextUser"] = ctx.ContextUser
-
-	ctx.Data["CanReadProjects"] = ctx.Org.CanReadUnit(ctx, unit.TypeProjects)
-	ctx.Data["CanReadPackages"] = ctx.Org.CanReadUnit(ctx, unit.TypePackages)
-	ctx.Data["CanReadCode"] = ctx.Org.CanReadUnit(ctx, unit.TypeCode)
-
-	ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
-	if len(ctx.ContextUser.Description) != 0 {
-		content, err := markdown.RenderString(markup.NewRenderContext(ctx), ctx.ContextUser.Description)
-		if err != nil {
-			ctx.ServerError("RenderString", err)
-			return
-		}
-		ctx.Data["RenderedDescription"] = content
-	}
-}
-
-// OrgAssignment returns a middleware to handle organization assignment
-func OrgAssignment(args ...bool) func(ctx *Context) {
-	return func(ctx *Context) {
-		HandleOrgAssignment(ctx, args...)
 	}
 }
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index 2c6373e03c..70019f3fa9 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -243,6 +243,7 @@ type WebhookForm struct {
 	Repository               bool
 	Release                  bool
 	Package                  bool
+	Status                   bool
 	Active                   bool
 	BranchFilter             string `binding:"GlobPattern"`
 	AuthorizationHeader      string
diff --git a/services/gitdiff/git_diff_tree.go b/services/gitdiff/git_diff_tree.go
new file mode 100644
index 0000000000..8039de145d
--- /dev/null
+++ b/services/gitdiff/git_diff_tree.go
@@ -0,0 +1,249 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitdiff
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/log"
+)
+
+type DiffTree struct {
+	Files []*DiffTreeRecord
+}
+
+type DiffTreeRecord struct {
+	// Status is one of 'added', 'deleted', 'modified', 'renamed', 'copied', 'typechanged', 'unmerged', 'unknown'
+	Status string
+
+	// For renames and copies, the percentage of similarity between the source and target of the move/rename.
+	Score uint8
+
+	HeadPath   string
+	BasePath   string
+	HeadMode   git.EntryMode
+	BaseMode   git.EntryMode
+	HeadBlobID string
+	BaseBlobID string
+}
+
+// GetDiffTree returns the list of path of the files that have changed between the two commits.
+// If useMergeBase is true, the diff will be calculated using the merge base of the two commits.
+// This is the same behavior as using a three-dot diff in git diff.
+func GetDiffTree(ctx context.Context, gitRepo *git.Repository, useMergeBase bool, baseSha, headSha string) (*DiffTree, error) {
+	gitDiffTreeRecords, err := runGitDiffTree(ctx, gitRepo, useMergeBase, baseSha, headSha)
+	if err != nil {
+		return nil, err
+	}
+
+	return &DiffTree{
+		Files: gitDiffTreeRecords,
+	}, nil
+}
+
+func runGitDiffTree(ctx context.Context, gitRepo *git.Repository, useMergeBase bool, baseSha, headSha string) ([]*DiffTreeRecord, error) {
+	useMergeBase, baseCommitID, headCommitID, err := validateGitDiffTreeArguments(gitRepo, useMergeBase, baseSha, headSha)
+	if err != nil {
+		return nil, err
+	}
+
+	cmd := git.NewCommand(ctx, "diff-tree", "--raw", "-r", "--find-renames", "--root")
+	if useMergeBase {
+		cmd.AddArguments("--merge-base")
+	}
+	cmd.AddDynamicArguments(baseCommitID, headCommitID)
+	stdout, _, runErr := cmd.RunStdString(&git.RunOpts{Dir: gitRepo.Path})
+	if runErr != nil {
+		log.Warn("git diff-tree: %v", runErr)
+		return nil, runErr
+	}
+
+	return parseGitDiffTree(strings.NewReader(stdout))
+}
+
+func validateGitDiffTreeArguments(gitRepo *git.Repository, useMergeBase bool, baseSha, headSha string) (shouldUseMergeBase bool, resolvedBaseSha, resolvedHeadSha string, err error) {
+	// if the head is empty its an error
+	if headSha == "" {
+		return false, "", "", fmt.Errorf("headSha is empty")
+	}
+
+	// if the head commit doesn't exist its and error
+	headCommit, err := gitRepo.GetCommit(headSha)
+	if err != nil {
+		return false, "", "", fmt.Errorf("failed to get commit headSha: %v", err)
+	}
+	headCommitID := headCommit.ID.String()
+
+	// if the base is empty we should use the parent of the head commit
+	if baseSha == "" {
+		// if the headCommit has no parent we should use an empty commit
+		// this can happen when we are generating a diff against an orphaned commit
+		if headCommit.ParentCount() == 0 {
+			objectFormat, err := gitRepo.GetObjectFormat()
+			if err != nil {
+				return false, "", "", err
+			}
+
+			// We set use merge base to false because we have no base commit
+			return false, objectFormat.EmptyTree().String(), headCommitID, nil
+		}
+
+		baseCommit, err := headCommit.Parent(0)
+		if err != nil {
+			return false, "", "", fmt.Errorf("baseSha is '', attempted to use parent of commit %s, got error: %v", headCommit.ID.String(), err)
+		}
+		return useMergeBase, baseCommit.ID.String(), headCommitID, nil
+	}
+
+	// try and get the base commit
+	baseCommit, err := gitRepo.GetCommit(baseSha)
+	// propagate the error if we couldn't get the base commit
+	if err != nil {
+		return useMergeBase, "", "", fmt.Errorf("failed to get base commit %s: %v", baseSha, err)
+	}
+
+	return useMergeBase, baseCommit.ID.String(), headCommit.ID.String(), nil
+}
+
+func parseGitDiffTree(gitOutput io.Reader) ([]*DiffTreeRecord, error) {
+	/*
+		The output of `git diff-tree --raw -r --find-renames` is of the form:
+
+		:<old_mode> <new_mode> <old_sha> <new_sha> <status>\t<path>
+
+		or for renames:
+
+		:<old_mode> <new_mode> <old_sha> <new_sha> <status>\t<old_path>\t<new_path>
+
+		See: <https://git-scm.com/docs/git-diff-tree#_raw_output_format> for more details
+	*/
+	results := make([]*DiffTreeRecord, 0)
+
+	lines := bufio.NewScanner(gitOutput)
+	for lines.Scan() {
+		line := lines.Text()
+
+		if len(line) == 0 {
+			continue
+		}
+
+		record, err := parseGitDiffTreeLine(line)
+		if err != nil {
+			return nil, err
+		}
+
+		results = append(results, record)
+	}
+
+	if err := lines.Err(); err != nil {
+		return nil, err
+	}
+
+	return results, nil
+}
+
+func parseGitDiffTreeLine(line string) (*DiffTreeRecord, error) {
+	line = strings.TrimPrefix(line, ":")
+	splitSections := strings.SplitN(line, "\t", 2)
+	if len(splitSections) < 2 {
+		return nil, fmt.Errorf("unparsable output for diff-tree --raw: `%s`)", line)
+	}
+
+	fields := strings.Fields(splitSections[0])
+	if len(fields) < 5 {
+		return nil, fmt.Errorf("unparsable output for diff-tree --raw: `%s`, expected 5 space delimited values got %d)", line, len(fields))
+	}
+
+	baseMode, err := git.ParseEntryMode(fields[0])
+	if err != nil {
+		return nil, err
+	}
+
+	headMode, err := git.ParseEntryMode(fields[1])
+	if err != nil {
+		return nil, err
+	}
+
+	baseBlobID := fields[2]
+	headBlobID := fields[3]
+
+	status, score, err := statusFromLetter(fields[4])
+	if err != nil {
+		return nil, fmt.Errorf("unparsable output for diff-tree --raw: %s, error: %s", line, err)
+	}
+
+	filePaths := strings.Split(splitSections[1], "\t")
+
+	var headPath, basePath string
+	if status == "renamed" {
+		if len(filePaths) != 2 {
+			return nil, fmt.Errorf("unparsable output for diff-tree --raw: `%s`, expected 2 paths found %d", line, len(filePaths))
+		}
+		basePath = filePaths[0]
+		headPath = filePaths[1]
+	} else {
+		basePath = filePaths[0]
+		headPath = filePaths[0]
+	}
+
+	return &DiffTreeRecord{
+		Status:     status,
+		Score:      score,
+		BaseMode:   baseMode,
+		HeadMode:   headMode,
+		BaseBlobID: baseBlobID,
+		HeadBlobID: headBlobID,
+		BasePath:   basePath,
+		HeadPath:   headPath,
+	}, nil
+}
+
+func statusFromLetter(rawStatus string) (status string, score uint8, err error) {
+	if len(rawStatus) < 1 {
+		return "", 0, fmt.Errorf("empty status letter")
+	}
+	switch rawStatus[0] {
+	case 'A':
+		return "added", 0, nil
+	case 'D':
+		return "deleted", 0, nil
+	case 'M':
+		return "modified", 0, nil
+	case 'R':
+		score, err = tryParseStatusScore(rawStatus)
+		return "renamed", score, err
+	case 'C':
+		score, err = tryParseStatusScore(rawStatus)
+		return "copied", score, err
+	case 'T':
+		return "typechanged", 0, nil
+	case 'U':
+		return "unmerged", 0, nil
+	case 'X':
+		return "unknown", 0, nil
+	default:
+		return "", 0, fmt.Errorf("unknown status letter: '%s'", rawStatus)
+	}
+}
+
+func tryParseStatusScore(rawStatus string) (uint8, error) {
+	if len(rawStatus) < 2 {
+		return 0, fmt.Errorf("status score missing")
+	}
+
+	score, err := strconv.ParseUint(rawStatus[1:], 10, 8)
+	if err != nil {
+		return 0, fmt.Errorf("failed to parse status score: %w", err)
+	} else if score > 100 {
+		return 0, fmt.Errorf("status score out of range: %d", score)
+	}
+
+	return uint8(score), nil
+}
diff --git a/services/gitdiff/git_diff_tree_test.go b/services/gitdiff/git_diff_tree_test.go
new file mode 100644
index 0000000000..313d279e95
--- /dev/null
+++ b/services/gitdiff/git_diff_tree_test.go
@@ -0,0 +1,427 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package gitdiff
+
+import (
+	"strings"
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/git"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGitDiffTree(t *testing.T) {
+	test := []struct {
+		Name         string
+		RepoPath     string
+		BaseSha      string
+		HeadSha      string
+		useMergeBase bool
+		Expected     *DiffTree
+	}{
+		{
+			Name:     "happy path",
+			RepoPath: "../../modules/git/tests/repos/repo5_pulls",
+			BaseSha:  "72866af952e98d02a73003501836074b286a78f6",
+			HeadSha:  "d8e0bbb45f200e67d9a784ce55bd90821af45ebd",
+			Expected: &DiffTree{
+				Files: []*DiffTreeRecord{
+					{
+						Status:     "modified",
+						HeadPath:   "LICENSE",
+						BasePath:   "LICENSE",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeBlob,
+						HeadBlobID: "ee469963e76ae1bb7ee83d7510df2864e6c8c640",
+						BaseBlobID: "c996f4725be8fc8c1d1c776e58c97ddc5d03b336",
+					},
+					{
+						Status:     "modified",
+						HeadPath:   "README.md",
+						BasePath:   "README.md",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeBlob,
+						HeadBlobID: "9dfc0a6257d8eff526f0cfaf6a8ea950f55a9dba",
+						BaseBlobID: "074e590b8e64898b02beef03ece83f962c94f54c",
+					},
+				},
+			},
+		},
+		{
+			Name:     "first commit (no parent)",
+			RepoPath: "../../modules/git/tests/repos/repo5_pulls",
+			HeadSha:  "72866af952e98d02a73003501836074b286a78f6",
+			Expected: &DiffTree{
+				Files: []*DiffTreeRecord{
+					{
+						Status:     "added",
+						HeadPath:   ".gitignore",
+						BasePath:   ".gitignore",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeNoEntry,
+						HeadBlobID: "f1c181ec9c5c921245027c6b452ecfc1d3626364",
+						BaseBlobID: "0000000000000000000000000000000000000000",
+					},
+					{
+						Status:     "added",
+						HeadPath:   "LICENSE",
+						BasePath:   "LICENSE",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeNoEntry,
+						HeadBlobID: "c996f4725be8fc8c1d1c776e58c97ddc5d03b336",
+						BaseBlobID: "0000000000000000000000000000000000000000",
+					},
+					{
+						Status:     "added",
+						HeadPath:   "README.md",
+						BasePath:   "README.md",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeNoEntry,
+						HeadBlobID: "074e590b8e64898b02beef03ece83f962c94f54c",
+						BaseBlobID: "0000000000000000000000000000000000000000",
+					},
+				},
+			},
+		},
+		{
+			Name:         "first commit (no parent), merge base = true",
+			RepoPath:     "../../modules/git/tests/repos/repo5_pulls",
+			HeadSha:      "72866af952e98d02a73003501836074b286a78f6",
+			useMergeBase: true,
+			Expected: &DiffTree{
+				Files: []*DiffTreeRecord{
+					{
+						Status:     "added",
+						HeadPath:   ".gitignore",
+						BasePath:   ".gitignore",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeNoEntry,
+						HeadBlobID: "f1c181ec9c5c921245027c6b452ecfc1d3626364",
+						BaseBlobID: "0000000000000000000000000000000000000000",
+					},
+					{
+						Status:     "added",
+						HeadPath:   "LICENSE",
+						BasePath:   "LICENSE",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeNoEntry,
+						HeadBlobID: "c996f4725be8fc8c1d1c776e58c97ddc5d03b336",
+						BaseBlobID: "0000000000000000000000000000000000000000",
+					},
+					{
+						Status:     "added",
+						HeadPath:   "README.md",
+						BasePath:   "README.md",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeNoEntry,
+						HeadBlobID: "074e590b8e64898b02beef03ece83f962c94f54c",
+						BaseBlobID: "0000000000000000000000000000000000000000",
+					},
+				},
+			},
+		},
+		{
+			Name:     "base and head same",
+			RepoPath: "../../modules/git/tests/repos/repo5_pulls",
+			BaseSha:  "ed8f4d2fa5b2420706580d191f5dd50c4e491f3f",
+			HeadSha:  "ed8f4d2fa5b2420706580d191f5dd50c4e491f3f",
+			Expected: &DiffTree{
+				Files: []*DiffTreeRecord{},
+			},
+		},
+		{
+			Name:         "useMergeBase false",
+			RepoPath:     "../../modules/git/tests/repos/repo5_pulls",
+			BaseSha:      "ed8f4d2fa5b2420706580d191f5dd50c4e491f3f",
+			HeadSha:      "111cac04bd7d20301964e27a93698aabb5781b80", // this commit can be found on the update-readme branch
+			useMergeBase: false,
+			Expected: &DiffTree{
+				Files: []*DiffTreeRecord{
+					{
+						Status:     "modified",
+						HeadPath:   "LICENSE",
+						BasePath:   "LICENSE",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeBlob,
+						HeadBlobID: "c996f4725be8fc8c1d1c776e58c97ddc5d03b336",
+						BaseBlobID: "ed5119b3c1f45547b6785bc03eac7f87570fa17f",
+					},
+
+					{
+						Status:     "modified",
+						HeadPath:   "README.md",
+						BasePath:   "README.md",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeBlob,
+						HeadBlobID: "fb39771a8865c9a67f2ab9b616c854805664553c",
+						BaseBlobID: "9dfc0a6257d8eff526f0cfaf6a8ea950f55a9dba",
+					},
+				},
+			},
+		},
+		{
+			Name:         "useMergeBase true",
+			RepoPath:     "../../modules/git/tests/repos/repo5_pulls",
+			BaseSha:      "ed8f4d2fa5b2420706580d191f5dd50c4e491f3f",
+			HeadSha:      "111cac04bd7d20301964e27a93698aabb5781b80", // this commit can be found on the update-readme branch
+			useMergeBase: true,
+			Expected: &DiffTree{
+				Files: []*DiffTreeRecord{
+					{
+						Status:     "modified",
+						HeadPath:   "README.md",
+						BasePath:   "README.md",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeBlob,
+						HeadBlobID: "fb39771a8865c9a67f2ab9b616c854805664553c",
+						BaseBlobID: "9dfc0a6257d8eff526f0cfaf6a8ea950f55a9dba",
+					},
+				},
+			},
+		},
+		{
+			Name:         "no base set",
+			RepoPath:     "../../modules/git/tests/repos/repo5_pulls",
+			HeadSha:      "d8e0bbb45f200e67d9a784ce55bd90821af45ebd", // this commit can be found on the update-readme branch
+			useMergeBase: false,
+			Expected: &DiffTree{
+				Files: []*DiffTreeRecord{
+					{
+						Status:     "modified",
+						HeadPath:   "LICENSE",
+						BasePath:   "LICENSE",
+						HeadMode:   git.EntryModeBlob,
+						BaseMode:   git.EntryModeBlob,
+						HeadBlobID: "ee469963e76ae1bb7ee83d7510df2864e6c8c640",
+						BaseBlobID: "ed5119b3c1f45547b6785bc03eac7f87570fa17f",
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range test {
+		t.Run(tt.Name, func(t *testing.T) {
+			gitRepo, err := git.OpenRepository(git.DefaultContext, tt.RepoPath)
+			assert.NoError(t, err)
+			defer gitRepo.Close()
+
+			diffPaths, err := GetDiffTree(db.DefaultContext, gitRepo, tt.useMergeBase, tt.BaseSha, tt.HeadSha)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.Expected, diffPaths)
+		})
+	}
+}
+
+func TestParseGitDiffTree(t *testing.T) {
+	test := []struct {
+		Name      string
+		GitOutput string
+		Expected  []*DiffTreeRecord
+	}{
+		{
+			Name:      "file change",
+			GitOutput: ":100644 100644 64e43d23bcd08db12563a0a4d84309cadb437e1a 5dbc7792b5bb228647cfcc8dfe65fc649119dedc M\tResources/views/curriculum/edit.blade.php",
+			Expected: []*DiffTreeRecord{
+				{
+					Status:     "modified",
+					HeadPath:   "Resources/views/curriculum/edit.blade.php",
+					BasePath:   "Resources/views/curriculum/edit.blade.php",
+					HeadMode:   git.EntryModeBlob,
+					BaseMode:   git.EntryModeBlob,
+					HeadBlobID: "5dbc7792b5bb228647cfcc8dfe65fc649119dedc",
+					BaseBlobID: "64e43d23bcd08db12563a0a4d84309cadb437e1a",
+				},
+			},
+		},
+		{
+			Name:      "file added",
+			GitOutput: ":000000 100644 0000000000000000000000000000000000000000 0063162fb403db15ceb0517b34ab782e4e58b619 A\tResources/views/class/index.blade.php",
+			Expected: []*DiffTreeRecord{
+				{
+					Status:     "added",
+					HeadPath:   "Resources/views/class/index.blade.php",
+					BasePath:   "Resources/views/class/index.blade.php",
+					HeadMode:   git.EntryModeBlob,
+					BaseMode:   git.EntryModeNoEntry,
+					HeadBlobID: "0063162fb403db15ceb0517b34ab782e4e58b619",
+					BaseBlobID: "0000000000000000000000000000000000000000",
+				},
+			},
+		},
+		{
+			Name:      "file deleted",
+			GitOutput: ":100644 000000 bac4286303c8c0017ea2f0a48c561ddcc0330a14 0000000000000000000000000000000000000000 D\tResources/views/classes/index.blade.php",
+			Expected: []*DiffTreeRecord{
+				{
+					Status:     "deleted",
+					HeadPath:   "Resources/views/classes/index.blade.php",
+					BasePath:   "Resources/views/classes/index.blade.php",
+					HeadMode:   git.EntryModeNoEntry,
+					BaseMode:   git.EntryModeBlob,
+					HeadBlobID: "0000000000000000000000000000000000000000",
+					BaseBlobID: "bac4286303c8c0017ea2f0a48c561ddcc0330a14",
+				},
+			},
+		},
+		{
+			Name:      "file renamed",
+			GitOutput: ":100644 100644 c8a055cfb45cd39747292983ad1797ceab40f5b1 97248f79a90aaf81fe7fd74b33c1cb182dd41783 R087\tDatabase/Seeders/AdminDatabaseSeeder.php\tDatabase/Seeders/AcademicDatabaseSeeder.php",
+			Expected: []*DiffTreeRecord{
+				{
+					Status:     "renamed",
+					Score:      87,
+					HeadPath:   "Database/Seeders/AcademicDatabaseSeeder.php",
+					BasePath:   "Database/Seeders/AdminDatabaseSeeder.php",
+					HeadMode:   git.EntryModeBlob,
+					BaseMode:   git.EntryModeBlob,
+					HeadBlobID: "97248f79a90aaf81fe7fd74b33c1cb182dd41783",
+					BaseBlobID: "c8a055cfb45cd39747292983ad1797ceab40f5b1",
+				},
+			},
+		},
+		{
+			Name:      "no changes",
+			GitOutput: ``,
+			Expected:  []*DiffTreeRecord{},
+		},
+		{
+			Name: "multiple changes",
+			GitOutput: ":000000 100644 0000000000000000000000000000000000000000 db736b44533a840981f1f17b7029d0f612b69550 A\tHttp/Controllers/ClassController.php\n" +
+				":100644 000000 9a4d2344d4d0145db7c91b3f3e123c74367d4ef4 0000000000000000000000000000000000000000 D\tHttp/Controllers/ClassesController.php\n" +
+				":100644 100644 f060d6aede65d423f49e7dc248dfa0d8835ef920 b82c8e39a3602dedadb44669956d6eb5b6a7cc86 M\tHttp/Controllers/ProgramDirectorController.php\n",
+			Expected: []*DiffTreeRecord{
+				{
+					Status:     "added",
+					HeadPath:   "Http/Controllers/ClassController.php",
+					BasePath:   "Http/Controllers/ClassController.php",
+					HeadMode:   git.EntryModeBlob,
+					BaseMode:   git.EntryModeNoEntry,
+					HeadBlobID: "db736b44533a840981f1f17b7029d0f612b69550",
+					BaseBlobID: "0000000000000000000000000000000000000000",
+				},
+				{
+					Status:     "deleted",
+					HeadPath:   "Http/Controllers/ClassesController.php",
+					BasePath:   "Http/Controllers/ClassesController.php",
+					HeadMode:   git.EntryModeNoEntry,
+					BaseMode:   git.EntryModeBlob,
+					HeadBlobID: "0000000000000000000000000000000000000000",
+					BaseBlobID: "9a4d2344d4d0145db7c91b3f3e123c74367d4ef4",
+				},
+				{
+					Status:     "modified",
+					HeadPath:   "Http/Controllers/ProgramDirectorController.php",
+					BasePath:   "Http/Controllers/ProgramDirectorController.php",
+					HeadMode:   git.EntryModeBlob,
+					BaseMode:   git.EntryModeBlob,
+					HeadBlobID: "b82c8e39a3602dedadb44669956d6eb5b6a7cc86",
+					BaseBlobID: "f060d6aede65d423f49e7dc248dfa0d8835ef920",
+				},
+			},
+		},
+		{
+			Name: "spaces in file path",
+			GitOutput: ":000000 100644 0000000000000000000000000000000000000000 db736b44533a840981f1f17b7029d0f612b69550 A\tHttp /Controllers/Class Controller.php\n" +
+				":100644 000000 9a4d2344d4d0145db7c91b3f3e123c74367d4ef4 0000000000000000000000000000000000000000 D\tHttp/Cont rollers/Classes Controller.php\n" +
+				":100644 100644 f060d6aede65d423f49e7dc248dfa0d8835ef920 b82c8e39a3602dedadb44669956d6eb5b6a7cc86 R010\tHttp/Controllers/Program Director Controller.php\tHttp/Cont rollers/ProgramDirectorController.php\n",
+			Expected: []*DiffTreeRecord{
+				{
+					Status:     "added",
+					HeadPath:   "Http /Controllers/Class Controller.php",
+					BasePath:   "Http /Controllers/Class Controller.php",
+					HeadMode:   git.EntryModeBlob,
+					BaseMode:   git.EntryModeNoEntry,
+					HeadBlobID: "db736b44533a840981f1f17b7029d0f612b69550",
+					BaseBlobID: "0000000000000000000000000000000000000000",
+				},
+				{
+					Status:     "deleted",
+					HeadPath:   "Http/Cont rollers/Classes Controller.php",
+					BasePath:   "Http/Cont rollers/Classes Controller.php",
+					HeadMode:   git.EntryModeNoEntry,
+					BaseMode:   git.EntryModeBlob,
+					HeadBlobID: "0000000000000000000000000000000000000000",
+					BaseBlobID: "9a4d2344d4d0145db7c91b3f3e123c74367d4ef4",
+				},
+				{
+					Status:     "renamed",
+					Score:      10,
+					HeadPath:   "Http/Cont rollers/ProgramDirectorController.php",
+					BasePath:   "Http/Controllers/Program Director Controller.php",
+					HeadMode:   git.EntryModeBlob,
+					BaseMode:   git.EntryModeBlob,
+					HeadBlobID: "b82c8e39a3602dedadb44669956d6eb5b6a7cc86",
+					BaseBlobID: "f060d6aede65d423f49e7dc248dfa0d8835ef920",
+				},
+			},
+		},
+		{
+			Name:      "file type changed",
+			GitOutput: ":100644 120000 344e0ca8aa791cc4164fb0ea645f334fd40d00f0 a7c2973de00bfdc6ca51d315f401b5199fe01dc3 T\twebpack.mix.js",
+			Expected: []*DiffTreeRecord{
+				{
+					Status:     "typechanged",
+					HeadPath:   "webpack.mix.js",
+					BasePath:   "webpack.mix.js",
+					HeadMode:   git.EntryModeSymlink,
+					BaseMode:   git.EntryModeBlob,
+					HeadBlobID: "a7c2973de00bfdc6ca51d315f401b5199fe01dc3",
+					BaseBlobID: "344e0ca8aa791cc4164fb0ea645f334fd40d00f0",
+				},
+			},
+		},
+	}
+
+	for _, tt := range test {
+		t.Run(tt.Name, func(t *testing.T) {
+			entries, err := parseGitDiffTree(strings.NewReader(tt.GitOutput))
+			assert.NoError(t, err)
+			assert.Equal(t, tt.Expected, entries)
+		})
+	}
+}
+
+func TestGitDiffTreeErrors(t *testing.T) {
+	test := []struct {
+		Name     string
+		RepoPath string
+		BaseSha  string
+		HeadSha  string
+	}{
+		{
+			Name:     "head doesn't exist",
+			RepoPath: "../../modules/git/tests/repos/repo5_pulls",
+			BaseSha:  "f32b0a9dfd09a60f616f29158f772cedd89942d2",
+			HeadSha:  "asdfasdfasdf",
+		},
+		{
+			Name:     "base doesn't exist",
+			RepoPath: "../../modules/git/tests/repos/repo5_pulls",
+			BaseSha:  "asdfasdfasdf",
+			HeadSha:  "f32b0a9dfd09a60f616f29158f772cedd89942d2",
+		},
+		{
+			Name:     "head not set",
+			RepoPath: "../../modules/git/tests/repos/repo5_pulls",
+			BaseSha:  "f32b0a9dfd09a60f616f29158f772cedd89942d2",
+		},
+	}
+
+	for _, tt := range test {
+		t.Run(tt.Name, func(t *testing.T) {
+			gitRepo, err := git.OpenRepository(git.DefaultContext, tt.RepoPath)
+			assert.NoError(t, err)
+			defer gitRepo.Close()
+
+			diffPaths, err := GetDiffTree(db.DefaultContext, gitRepo, true, tt.BaseSha, tt.HeadSha)
+			assert.Error(t, err)
+			assert.Nil(t, diffPaths)
+		})
+	}
+}
diff --git a/services/gitdiff/gitdiff_test.go b/services/gitdiff/gitdiff_test.go
index 1017d188dd..ca9b5a6f4e 100644
--- a/services/gitdiff/gitdiff_test.go
+++ b/services/gitdiff/gitdiff_test.go
@@ -5,6 +5,7 @@
 package gitdiff
 
 import (
+	"context"
 	"strconv"
 	"strings"
 	"testing"
@@ -628,23 +629,25 @@ func TestDiffLine_GetCommentSide(t *testing.T) {
 }
 
 func TestGetDiffRangeWithWhitespaceBehavior(t *testing.T) {
-	gitRepo, err := git.OpenRepository(git.DefaultContext, "./testdata/academic-module")
+	gitRepo, err := git.OpenRepository(context.Background(), "../../modules/git/tests/repos/repo5_pulls")
 	require.NoError(t, err)
 
 	defer gitRepo.Close()
 	for _, behavior := range []git.TrustedCmdArgs{{"-w"}, {"--ignore-space-at-eol"}, {"-b"}, nil} {
-		diffs, err := GetDiff(db.DefaultContext, gitRepo,
+		diffs, err := GetDiff(context.Background(), gitRepo,
 			&DiffOptions{
-				AfterCommitID:      "bd7063cc7c04689c4d082183d32a604ed27a24f9",
-				BeforeCommitID:     "559c156f8e0178b71cb44355428f24001b08fc68",
+				AfterCommitID:      "d8e0bbb45f200e67d9a784ce55bd90821af45ebd",
+				BeforeCommitID:     "72866af952e98d02a73003501836074b286a78f6",
 				MaxLines:           setting.Git.MaxGitDiffLines,
 				MaxLineCharacters:  setting.Git.MaxGitDiffLineCharacters,
-				MaxFiles:           setting.Git.MaxGitDiffFiles,
+				MaxFiles:           1,
 				WhitespaceBehavior: behavior,
 			})
-		assert.NoError(t, err, "Error when diff with %s", behavior)
+		require.NoError(t, err, "Error when diff with WhitespaceBehavior=%s", behavior)
+		assert.True(t, diffs.IsIncomplete)
+		assert.Len(t, diffs.Files, 1)
 		for _, f := range diffs.Files {
-			assert.NotEmpty(t, f.Sections, "%s should have sections", f.Name)
+			assert.NotEmpty(t, f.Sections, "Diff file %q should have sections", f.Name)
 		}
 	}
 }
diff --git a/services/gitdiff/testdata/academic-module/HEAD b/services/gitdiff/testdata/academic-module/HEAD
deleted file mode 100644
index cb089cd89a..0000000000
--- a/services/gitdiff/testdata/academic-module/HEAD
+++ /dev/null
@@ -1 +0,0 @@
-ref: refs/heads/master
diff --git a/services/gitdiff/testdata/academic-module/config b/services/gitdiff/testdata/academic-module/config
deleted file mode 100644
index 1bc26be514..0000000000
--- a/services/gitdiff/testdata/academic-module/config
+++ /dev/null
@@ -1,10 +0,0 @@
-[core]
-	repositoryformatversion = 0
-	filemode = true
-	bare = false
-	logallrefupdates = true
-	ignorecase = true
-	precomposeunicode = true
-[branch "master"]
-	remote = origin
-	merge = refs/heads/master
diff --git a/services/gitdiff/testdata/academic-module/index b/services/gitdiff/testdata/academic-module/index
deleted file mode 100644
index e712c906e3..0000000000
Binary files a/services/gitdiff/testdata/academic-module/index and /dev/null differ
diff --git a/services/gitdiff/testdata/academic-module/logs/HEAD b/services/gitdiff/testdata/academic-module/logs/HEAD
deleted file mode 100644
index 16b2e1c0f6..0000000000
--- a/services/gitdiff/testdata/academic-module/logs/HEAD
+++ /dev/null
@@ -1 +0,0 @@
-0000000000000000000000000000000000000000 bd7063cc7c04689c4d082183d32a604ed27a24f9 Lunny Xiao <xiaolunwen@gmail.com> 1574829684 +0800	clone: from https://try.gitea.io/shemgp-aiias/academic-module
diff --git a/services/gitdiff/testdata/academic-module/logs/refs/heads/master b/services/gitdiff/testdata/academic-module/logs/refs/heads/master
deleted file mode 100644
index 16b2e1c0f6..0000000000
--- a/services/gitdiff/testdata/academic-module/logs/refs/heads/master
+++ /dev/null
@@ -1 +0,0 @@
-0000000000000000000000000000000000000000 bd7063cc7c04689c4d082183d32a604ed27a24f9 Lunny Xiao <xiaolunwen@gmail.com> 1574829684 +0800	clone: from https://try.gitea.io/shemgp-aiias/academic-module
diff --git a/services/gitdiff/testdata/academic-module/logs/refs/remotes/origin/HEAD b/services/gitdiff/testdata/academic-module/logs/refs/remotes/origin/HEAD
deleted file mode 100644
index 16b2e1c0f6..0000000000
--- a/services/gitdiff/testdata/academic-module/logs/refs/remotes/origin/HEAD
+++ /dev/null
@@ -1 +0,0 @@
-0000000000000000000000000000000000000000 bd7063cc7c04689c4d082183d32a604ed27a24f9 Lunny Xiao <xiaolunwen@gmail.com> 1574829684 +0800	clone: from https://try.gitea.io/shemgp-aiias/academic-module
diff --git a/services/gitdiff/testdata/academic-module/objects/pack/pack-597efbc3613c7ba790e33b178fd9fc1fe17b4245.idx b/services/gitdiff/testdata/academic-module/objects/pack/pack-597efbc3613c7ba790e33b178fd9fc1fe17b4245.idx
deleted file mode 100644
index 4d759aa504..0000000000
Binary files a/services/gitdiff/testdata/academic-module/objects/pack/pack-597efbc3613c7ba790e33b178fd9fc1fe17b4245.idx and /dev/null differ
diff --git a/services/gitdiff/testdata/academic-module/objects/pack/pack-597efbc3613c7ba790e33b178fd9fc1fe17b4245.pack b/services/gitdiff/testdata/academic-module/objects/pack/pack-597efbc3613c7ba790e33b178fd9fc1fe17b4245.pack
deleted file mode 100644
index 2dc49cfded..0000000000
Binary files a/services/gitdiff/testdata/academic-module/objects/pack/pack-597efbc3613c7ba790e33b178fd9fc1fe17b4245.pack and /dev/null differ
diff --git a/services/gitdiff/testdata/academic-module/packed-refs b/services/gitdiff/testdata/academic-module/packed-refs
deleted file mode 100644
index 13b5611650..0000000000
--- a/services/gitdiff/testdata/academic-module/packed-refs
+++ /dev/null
@@ -1,2 +0,0 @@
-# pack-refs with: peeled fully-peeled sorted 
-bd7063cc7c04689c4d082183d32a604ed27a24f9 refs/remotes/origin/master
diff --git a/services/gitdiff/testdata/academic-module/refs/heads/master b/services/gitdiff/testdata/academic-module/refs/heads/master
deleted file mode 100644
index bd2b56eaf4..0000000000
--- a/services/gitdiff/testdata/academic-module/refs/heads/master
+++ /dev/null
@@ -1 +0,0 @@
-bd7063cc7c04689c4d082183d32a604ed27a24f9
diff --git a/services/gitdiff/testdata/academic-module/refs/remotes/origin/HEAD b/services/gitdiff/testdata/academic-module/refs/remotes/origin/HEAD
deleted file mode 100644
index 6efe28fff8..0000000000
--- a/services/gitdiff/testdata/academic-module/refs/remotes/origin/HEAD
+++ /dev/null
@@ -1 +0,0 @@
-ref: refs/remotes/origin/master
diff --git a/services/issue/suggestion.go b/services/issue/suggestion.go
new file mode 100644
index 0000000000..22eddb1904
--- /dev/null
+++ b/services/issue/suggestion.go
@@ -0,0 +1,73 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package issue
+
+import (
+	"context"
+	"strconv"
+
+	issues_model "code.gitea.io/gitea/models/issues"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/modules/optional"
+	"code.gitea.io/gitea/modules/structs"
+)
+
+func GetSuggestion(ctx context.Context, repo *repo_model.Repository, isPull optional.Option[bool], keyword string) ([]*structs.Issue, error) {
+	var issues issues_model.IssueList
+	var err error
+	pageSize := 5
+	if keyword == "" {
+		issues, err = issues_model.FindLatestUpdatedIssues(ctx, repo.ID, isPull, pageSize)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		indexKeyword, _ := strconv.ParseInt(keyword, 10, 64)
+		var issueByIndex *issues_model.Issue
+		var excludedID int64
+		if indexKeyword > 0 {
+			issueByIndex, err = issues_model.GetIssueByIndex(ctx, repo.ID, indexKeyword)
+			if err != nil && !issues_model.IsErrIssueNotExist(err) {
+				return nil, err
+			}
+			if issueByIndex != nil {
+				excludedID = issueByIndex.ID
+				pageSize--
+			}
+		}
+
+		issues, err = issues_model.FindIssuesSuggestionByKeyword(ctx, repo.ID, keyword, isPull, excludedID, pageSize)
+		if err != nil {
+			return nil, err
+		}
+
+		if issueByIndex != nil {
+			issues = append([]*issues_model.Issue{issueByIndex}, issues...)
+		}
+	}
+
+	if err := issues.LoadPullRequests(ctx); err != nil {
+		return nil, err
+	}
+
+	suggestions := make([]*structs.Issue, 0, len(issues))
+	for _, issue := range issues {
+		suggestion := &structs.Issue{
+			ID:    issue.ID,
+			Index: issue.Index,
+			Title: issue.Title,
+			State: issue.State(),
+		}
+
+		if issue.IsPull && issue.PullRequest != nil {
+			suggestion.PullRequest = &structs.PullRequestMeta{
+				HasMerged:        issue.PullRequest.HasMerged,
+				IsWorkInProgress: issue.PullRequest.IsWorkInProgress(ctx),
+			}
+		}
+		suggestions = append(suggestions, suggestion)
+	}
+
+	return suggestions, nil
+}
diff --git a/services/issue/suggestion_test.go b/services/issue/suggestion_test.go
new file mode 100644
index 0000000000..84cfd520ac
--- /dev/null
+++ b/services/issue/suggestion_test.go
@@ -0,0 +1,57 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package issue
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	"code.gitea.io/gitea/modules/optional"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_Suggestion(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+	testCases := []struct {
+		keyword         string
+		isPull          optional.Option[bool]
+		expectedIndexes []int64
+	}{
+		{
+			keyword:         "",
+			expectedIndexes: []int64{5, 1, 4, 2, 3},
+		},
+		{
+			keyword:         "1",
+			expectedIndexes: []int64{1},
+		},
+		{
+			keyword:         "issue",
+			expectedIndexes: []int64{4, 1, 2, 3},
+		},
+		{
+			keyword:         "pull",
+			expectedIndexes: []int64{5},
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.keyword, func(t *testing.T) {
+			issues, err := GetSuggestion(db.DefaultContext, repo1, testCase.isPull, testCase.keyword)
+			assert.NoError(t, err)
+
+			issueIndexes := make([]int64, 0, len(issues))
+			for _, issue := range issues {
+				issueIndexes = append(issueIndexes, issue.Index)
+			}
+			assert.EqualValues(t, testCase.expectedIndexes, issueIndexes)
+		})
+	}
+}
diff --git a/services/lfs/server.go b/services/lfs/server.go
index a77623fdc1..c4866edaab 100644
--- a/services/lfs/server.go
+++ b/services/lfs/server.go
@@ -134,7 +134,9 @@ func DownloadHandler(ctx *context.Context) {
 	}
 
 	contentLength := toByte + 1 - fromByte
-	ctx.Resp.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
+	contentLengthStr := strconv.FormatInt(contentLength, 10)
+	ctx.Resp.Header().Set("Content-Length", contentLengthStr)
+	ctx.Resp.Header().Set("X-Gitea-LFS-Content-Length", contentLengthStr) // we need this header to make sure it won't be affected by reverse proxy or compression
 	ctx.Resp.Header().Set("Content-Type", "application/octet-stream")
 
 	filename := ctx.PathParam("filename")
diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go
index 36cef486c9..8298ac4a34 100644
--- a/services/mailer/mail_test.go
+++ b/services/mailer/mail_test.go
@@ -85,7 +85,7 @@ func TestComposeIssueCommentMessage(t *testing.T) {
 
 	recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
 	msgs, err := composeIssueCommentMessages(&mailCommentContext{
-		Context: context.TODO(), // TODO: use a correct context
+		Context: context.TODO(),
 		Issue:   issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
 		Content: fmt.Sprintf("test @%s %s#%d body", doer.Name, issue.Repo.FullName(), issue.Index),
 		Comment: comment,
@@ -131,7 +131,7 @@ func TestComposeIssueMessage(t *testing.T) {
 
 	recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
 	msgs, err := composeIssueCommentMessages(&mailCommentContext{
-		Context: context.TODO(), // TODO: use a correct context
+		Context: context.TODO(),
 		Issue:   issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
 		Content: "test body",
 	}, "en-US", recipients, false, "issue create")
@@ -178,14 +178,14 @@ func TestTemplateSelection(t *testing.T) {
 	}
 
 	msg := testComposeIssueCommentMessage(t, &mailCommentContext{
-		Context: context.TODO(), // TODO: use a correct context
+		Context: context.TODO(),
 		Issue:   issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
 		Content: "test body",
 	}, recipients, false, "TestTemplateSelection")
 	expect(t, msg, "issue/new/subject", "issue/new/body")
 
 	msg = testComposeIssueCommentMessage(t, &mailCommentContext{
-		Context: context.TODO(), // TODO: use a correct context
+		Context: context.TODO(),
 		Issue:   issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
 		Content: "test body", Comment: comment,
 	}, recipients, false, "TestTemplateSelection")
@@ -194,14 +194,14 @@ func TestTemplateSelection(t *testing.T) {
 	pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2, Repo: repo, Poster: doer})
 	comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 4, Issue: pull})
 	msg = testComposeIssueCommentMessage(t, &mailCommentContext{
-		Context: context.TODO(), // TODO: use a correct context
+		Context: context.TODO(),
 		Issue:   pull, Doer: doer, ActionType: activities_model.ActionCommentPull,
 		Content: "test body", Comment: comment,
 	}, recipients, false, "TestTemplateSelection")
 	expect(t, msg, "pull/comment/subject", "pull/comment/body")
 
 	msg = testComposeIssueCommentMessage(t, &mailCommentContext{
-		Context: context.TODO(), // TODO: use a correct context
+		Context: context.TODO(),
 		Issue:   issue, Doer: doer, ActionType: activities_model.ActionCloseIssue,
 		Content: "test body", Comment: comment,
 	}, recipients, false, "TestTemplateSelection")
@@ -220,7 +220,7 @@ func TestTemplateServices(t *testing.T) {
 
 		recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
 		msg := testComposeIssueCommentMessage(t, &mailCommentContext{
-			Context: context.TODO(), // TODO: use a correct context
+			Context: context.TODO(),
 			Issue:   issue, Doer: doer, ActionType: actionType,
 			Content: "test body", Comment: comment,
 		}, recipients, fromMention, "TestTemplateServices")
@@ -263,7 +263,7 @@ func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recip
 func TestGenerateAdditionalHeaders(t *testing.T) {
 	doer, _, issue, _ := prepareMailerTest(t)
 
-	ctx := &mailCommentContext{Context: context.TODO() /* TODO: use a correct context */, Issue: issue, Doer: doer}
+	ctx := &mailCommentContext{Context: context.TODO(), Issue: issue, Doer: doer}
 	recipient := &user_model.User{Name: "test", Email: "test@gitea.com"}
 
 	headers := generateAdditionalHeaders(ctx, "dummy-reason", recipient)
diff --git a/services/migrations/codebase.go b/services/migrations/codebase.go
index 492fc908e9..880dd21497 100644
--- a/services/migrations/codebase.go
+++ b/services/migrations/codebase.go
@@ -66,7 +66,6 @@ type codebaseUser struct {
 // from Codebase
 type CodebaseDownloader struct {
 	base.NullDownloader
-	ctx           context.Context
 	client        *http.Client
 	baseURL       *url.URL
 	projectURL    *url.URL
@@ -77,17 +76,11 @@ type CodebaseDownloader struct {
 	commitMap     map[string]string
 }
 
-// SetContext set context
-func (d *CodebaseDownloader) SetContext(ctx context.Context) {
-	d.ctx = ctx
-}
-
 // NewCodebaseDownloader creates a new downloader
-func NewCodebaseDownloader(ctx context.Context, projectURL *url.URL, project, repoName, username, password string) *CodebaseDownloader {
+func NewCodebaseDownloader(_ context.Context, projectURL *url.URL, project, repoName, username, password string) *CodebaseDownloader {
 	baseURL, _ := url.Parse("https://api3.codebasehq.com")
 
 	downloader := &CodebaseDownloader{
-		ctx:        ctx,
 		baseURL:    baseURL,
 		projectURL: projectURL,
 		project:    project,
@@ -127,7 +120,7 @@ func (d *CodebaseDownloader) FormatCloneURL(opts base.MigrateOptions, remoteAddr
 	return opts.CloneAddr, nil
 }
 
-func (d *CodebaseDownloader) callAPI(endpoint string, parameter map[string]string, result any) error {
+func (d *CodebaseDownloader) callAPI(ctx context.Context, endpoint string, parameter map[string]string, result any) error {
 	u, err := d.baseURL.Parse(endpoint)
 	if err != nil {
 		return err
@@ -141,7 +134,7 @@ func (d *CodebaseDownloader) callAPI(endpoint string, parameter map[string]strin
 		u.RawQuery = query.Encode()
 	}
 
-	req, err := http.NewRequestWithContext(d.ctx, "GET", u.String(), nil)
+	req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
 	if err != nil {
 		return err
 	}
@@ -158,7 +151,7 @@ func (d *CodebaseDownloader) callAPI(endpoint string, parameter map[string]strin
 
 // GetRepoInfo returns repository information
 // https://support.codebasehq.com/kb/projects
-func (d *CodebaseDownloader) GetRepoInfo() (*base.Repository, error) {
+func (d *CodebaseDownloader) GetRepoInfo(ctx context.Context) (*base.Repository, error) {
 	var rawRepository struct {
 		XMLName     xml.Name `xml:"repository"`
 		Name        string   `xml:"name"`
@@ -169,6 +162,7 @@ func (d *CodebaseDownloader) GetRepoInfo() (*base.Repository, error) {
 	}
 
 	err := d.callAPI(
+		ctx,
 		fmt.Sprintf("/%s/%s", d.project, d.repoName),
 		nil,
 		&rawRepository,
@@ -187,7 +181,7 @@ func (d *CodebaseDownloader) GetRepoInfo() (*base.Repository, error) {
 
 // GetMilestones returns milestones
 // https://support.codebasehq.com/kb/tickets-and-milestones/milestones
-func (d *CodebaseDownloader) GetMilestones() ([]*base.Milestone, error) {
+func (d *CodebaseDownloader) GetMilestones(ctx context.Context) ([]*base.Milestone, error) {
 	var rawMilestones struct {
 		XMLName            xml.Name `xml:"ticketing-milestone"`
 		Type               string   `xml:"type,attr"`
@@ -209,6 +203,7 @@ func (d *CodebaseDownloader) GetMilestones() ([]*base.Milestone, error) {
 	}
 
 	err := d.callAPI(
+		ctx,
 		fmt.Sprintf("/%s/milestones", d.project),
 		nil,
 		&rawMilestones,
@@ -245,7 +240,7 @@ func (d *CodebaseDownloader) GetMilestones() ([]*base.Milestone, error) {
 
 // GetLabels returns labels
 // https://support.codebasehq.com/kb/tickets-and-milestones/statuses-priorities-and-categories
-func (d *CodebaseDownloader) GetLabels() ([]*base.Label, error) {
+func (d *CodebaseDownloader) GetLabels(ctx context.Context) ([]*base.Label, error) {
 	var rawTypes struct {
 		XMLName       xml.Name `xml:"ticketing-types"`
 		Type          string   `xml:"type,attr"`
@@ -259,6 +254,7 @@ func (d *CodebaseDownloader) GetLabels() ([]*base.Label, error) {
 	}
 
 	err := d.callAPI(
+		ctx,
 		fmt.Sprintf("/%s/tickets/types", d.project),
 		nil,
 		&rawTypes,
@@ -284,7 +280,7 @@ type codebaseIssueContext struct {
 // GetIssues returns issues, limits are not supported
 // https://support.codebasehq.com/kb/tickets-and-milestones
 // https://support.codebasehq.com/kb/tickets-and-milestones/updating-tickets
-func (d *CodebaseDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
+func (d *CodebaseDownloader) GetIssues(ctx context.Context, _, _ int) ([]*base.Issue, bool, error) {
 	var rawIssues struct {
 		XMLName xml.Name `xml:"tickets"`
 		Type    string   `xml:"type,attr"`
@@ -324,6 +320,7 @@ func (d *CodebaseDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool,
 	}
 
 	err := d.callAPI(
+		ctx,
 		fmt.Sprintf("/%s/tickets", d.project),
 		nil,
 		&rawIssues,
@@ -358,6 +355,7 @@ func (d *CodebaseDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool,
 			} `xml:"ticket-note"`
 		}
 		err := d.callAPI(
+			ctx,
 			fmt.Sprintf("/%s/tickets/%d/notes", d.project, issue.TicketID.Value),
 			nil,
 			&notes,
@@ -370,7 +368,7 @@ func (d *CodebaseDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool,
 			if len(note.Content) == 0 {
 				continue
 			}
-			poster := d.tryGetUser(note.UserID.Value)
+			poster := d.tryGetUser(ctx, note.UserID.Value)
 			comments = append(comments, &base.Comment{
 				IssueIndex:  issue.TicketID.Value,
 				Index:       note.ID.Value,
@@ -390,7 +388,7 @@ func (d *CodebaseDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool,
 		if issue.Status.TreatAsClosed.Value {
 			state = "closed"
 		}
-		poster := d.tryGetUser(issue.ReporterID.Value)
+		poster := d.tryGetUser(ctx, issue.ReporterID.Value)
 		issues = append(issues, &base.Issue{
 			Title:       issue.Summary,
 			Number:      issue.TicketID.Value,
@@ -419,7 +417,7 @@ func (d *CodebaseDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool,
 }
 
 // GetComments returns comments
-func (d *CodebaseDownloader) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
+func (d *CodebaseDownloader) GetComments(_ context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
 	context, ok := commentable.GetContext().(codebaseIssueContext)
 	if !ok {
 		return nil, false, fmt.Errorf("unexpected context: %+v", commentable.GetContext())
@@ -430,7 +428,7 @@ func (d *CodebaseDownloader) GetComments(commentable base.Commentable) ([]*base.
 
 // GetPullRequests returns pull requests
 // https://support.codebasehq.com/kb/repositories/merge-requests
-func (d *CodebaseDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
+func (d *CodebaseDownloader) GetPullRequests(ctx context.Context, page, perPage int) ([]*base.PullRequest, bool, error) {
 	var rawMergeRequests struct {
 		XMLName      xml.Name `xml:"merge-requests"`
 		Type         string   `xml:"type,attr"`
@@ -443,6 +441,7 @@ func (d *CodebaseDownloader) GetPullRequests(page, perPage int) ([]*base.PullReq
 	}
 
 	err := d.callAPI(
+		ctx,
 		fmt.Sprintf("/%s/%s/merge_requests", d.project, d.repoName),
 		map[string]string{
 			"query":  `"Target Project" is "` + d.repoName + `"`,
@@ -503,6 +502,7 @@ func (d *CodebaseDownloader) GetPullRequests(page, perPage int) ([]*base.PullReq
 			} `xml:"comments"`
 		}
 		err := d.callAPI(
+			ctx,
 			fmt.Sprintf("/%s/%s/merge_requests/%d", d.project, d.repoName, mr.ID.Value),
 			nil,
 			&rawMergeRequest,
@@ -531,7 +531,7 @@ func (d *CodebaseDownloader) GetPullRequests(page, perPage int) ([]*base.PullReq
 				}
 				continue
 			}
-			poster := d.tryGetUser(comment.UserID.Value)
+			poster := d.tryGetUser(ctx, comment.UserID.Value)
 			comments = append(comments, &base.Comment{
 				IssueIndex:  number,
 				Index:       comment.ID.Value,
@@ -547,7 +547,7 @@ func (d *CodebaseDownloader) GetPullRequests(page, perPage int) ([]*base.PullReq
 			comments = append(comments, &base.Comment{})
 		}
 
-		poster := d.tryGetUser(rawMergeRequest.UserID.Value)
+		poster := d.tryGetUser(ctx, rawMergeRequest.UserID.Value)
 
 		pullRequests = append(pullRequests, &base.PullRequest{
 			Title:       rawMergeRequest.Subject,
@@ -563,12 +563,12 @@ func (d *CodebaseDownloader) GetPullRequests(page, perPage int) ([]*base.PullReq
 			MergedTime:  mergedTime,
 			Head: base.PullRequestBranch{
 				Ref:      rawMergeRequest.SourceRef,
-				SHA:      d.getHeadCommit(rawMergeRequest.SourceRef),
+				SHA:      d.getHeadCommit(ctx, rawMergeRequest.SourceRef),
 				RepoName: d.repoName,
 			},
 			Base: base.PullRequestBranch{
 				Ref:      rawMergeRequest.TargetRef,
-				SHA:      d.getHeadCommit(rawMergeRequest.TargetRef),
+				SHA:      d.getHeadCommit(ctx, rawMergeRequest.TargetRef),
 				RepoName: d.repoName,
 			},
 			ForeignIndex: rawMergeRequest.ID.Value,
@@ -584,7 +584,7 @@ func (d *CodebaseDownloader) GetPullRequests(page, perPage int) ([]*base.PullReq
 	return pullRequests, true, nil
 }
 
-func (d *CodebaseDownloader) tryGetUser(userID int64) *codebaseUser {
+func (d *CodebaseDownloader) tryGetUser(ctx context.Context, userID int64) *codebaseUser {
 	if len(d.userMap) == 0 {
 		var rawUsers struct {
 			XMLName xml.Name `xml:"users"`
@@ -602,6 +602,7 @@ func (d *CodebaseDownloader) tryGetUser(userID int64) *codebaseUser {
 		}
 
 		err := d.callAPI(
+			ctx,
 			"/users",
 			nil,
 			&rawUsers,
@@ -627,7 +628,7 @@ func (d *CodebaseDownloader) tryGetUser(userID int64) *codebaseUser {
 	return user
 }
 
-func (d *CodebaseDownloader) getHeadCommit(ref string) string {
+func (d *CodebaseDownloader) getHeadCommit(ctx context.Context, ref string) string {
 	commitRef, ok := d.commitMap[ref]
 	if !ok {
 		var rawCommits struct {
@@ -638,6 +639,7 @@ func (d *CodebaseDownloader) getHeadCommit(ref string) string {
 			} `xml:"commit"`
 		}
 		err := d.callAPI(
+			ctx,
 			fmt.Sprintf("/%s/%s/commits/%s", d.project, d.repoName, ref),
 			nil,
 			&rawCommits,
diff --git a/services/migrations/codebase_test.go b/services/migrations/codebase_test.go
index 68721e0641..ec4da1bff5 100644
--- a/services/migrations/codebase_test.go
+++ b/services/migrations/codebase_test.go
@@ -30,9 +30,9 @@ func TestCodebaseDownloadRepo(t *testing.T) {
 	if cloneUser != "" {
 		u.User = url.UserPassword(cloneUser, clonePassword)
 	}
-
+	ctx := context.Background()
 	factory := &CodebaseDownloaderFactory{}
-	downloader, err := factory.New(context.Background(), base.MigrateOptions{
+	downloader, err := factory.New(ctx, base.MigrateOptions{
 		CloneAddr:    u.String(),
 		AuthUsername: apiUser,
 		AuthPassword: apiPassword,
@@ -40,7 +40,7 @@ func TestCodebaseDownloadRepo(t *testing.T) {
 	if err != nil {
 		t.Fatalf("Error creating Codebase downloader: %v", err)
 	}
-	repo, err := downloader.GetRepoInfo()
+	repo, err := downloader.GetRepoInfo(ctx)
 	assert.NoError(t, err)
 	assertRepositoryEqual(t, &base.Repository{
 		Name:        "test",
@@ -50,7 +50,7 @@ func TestCodebaseDownloadRepo(t *testing.T) {
 		OriginalURL: cloneAddr,
 	}, repo)
 
-	milestones, err := downloader.GetMilestones()
+	milestones, err := downloader.GetMilestones(ctx)
 	assert.NoError(t, err)
 	assertMilestonesEqual(t, []*base.Milestone{
 		{
@@ -65,11 +65,11 @@ func TestCodebaseDownloadRepo(t *testing.T) {
 		},
 	}, milestones)
 
-	labels, err := downloader.GetLabels()
+	labels, err := downloader.GetLabels(ctx)
 	assert.NoError(t, err)
 	assert.Len(t, labels, 4)
 
-	issues, isEnd, err := downloader.GetIssues(1, 2)
+	issues, isEnd, err := downloader.GetIssues(ctx, 1, 2)
 	assert.NoError(t, err)
 	assert.True(t, isEnd)
 	assertIssuesEqual(t, []*base.Issue{
@@ -106,7 +106,7 @@ func TestCodebaseDownloadRepo(t *testing.T) {
 		},
 	}, issues)
 
-	comments, _, err := downloader.GetComments(issues[0])
+	comments, _, err := downloader.GetComments(ctx, issues[0])
 	assert.NoError(t, err)
 	assertCommentsEqual(t, []*base.Comment{
 		{
@@ -119,7 +119,7 @@ func TestCodebaseDownloadRepo(t *testing.T) {
 		},
 	}, comments)
 
-	prs, _, err := downloader.GetPullRequests(1, 1)
+	prs, _, err := downloader.GetPullRequests(ctx, 1, 1)
 	assert.NoError(t, err)
 	assertPullRequestsEqual(t, []*base.PullRequest{
 		{
@@ -144,7 +144,7 @@ func TestCodebaseDownloadRepo(t *testing.T) {
 		},
 	}, prs)
 
-	rvs, err := downloader.GetReviews(prs[0])
+	rvs, err := downloader.GetReviews(ctx, prs[0])
 	assert.NoError(t, err)
 	assert.Empty(t, rvs)
 }
diff --git a/services/migrations/codecommit.go b/services/migrations/codecommit.go
index fead527f5b..c45f9e5943 100644
--- a/services/migrations/codecommit.go
+++ b/services/migrations/codecommit.go
@@ -62,9 +62,8 @@ func (c *CodeCommitDownloaderFactory) GitServiceType() structs.GitServiceType {
 	return structs.CodeCommitService
 }
 
-func NewCodeCommitDownloader(ctx context.Context, repoName, baseURL, accessKeyID, secretAccessKey, region string) *CodeCommitDownloader {
+func NewCodeCommitDownloader(_ context.Context, repoName, baseURL, accessKeyID, secretAccessKey, region string) *CodeCommitDownloader {
 	downloader := CodeCommitDownloader{
-		ctx:      ctx,
 		repoName: repoName,
 		baseURL:  baseURL,
 		codeCommitClient: codecommit.New(codecommit.Options{
@@ -79,21 +78,15 @@ func NewCodeCommitDownloader(ctx context.Context, repoName, baseURL, accessKeyID
 // CodeCommitDownloader implements a downloader for AWS CodeCommit
 type CodeCommitDownloader struct {
 	base.NullDownloader
-	ctx               context.Context
 	codeCommitClient  *codecommit.Client
 	repoName          string
 	baseURL           string
 	allPullRequestIDs []string
 }
 
-// SetContext set context
-func (c *CodeCommitDownloader) SetContext(ctx context.Context) {
-	c.ctx = ctx
-}
-
 // GetRepoInfo returns a repository information
-func (c *CodeCommitDownloader) GetRepoInfo() (*base.Repository, error) {
-	output, err := c.codeCommitClient.GetRepository(c.ctx, &codecommit.GetRepositoryInput{
+func (c *CodeCommitDownloader) GetRepoInfo(ctx context.Context) (*base.Repository, error) {
+	output, err := c.codeCommitClient.GetRepository(ctx, &codecommit.GetRepositoryInput{
 		RepositoryName: util.ToPointer(c.repoName),
 	})
 	if err != nil {
@@ -117,14 +110,14 @@ func (c *CodeCommitDownloader) GetRepoInfo() (*base.Repository, error) {
 }
 
 // GetComments returns comments of an issue or PR
-func (c *CodeCommitDownloader) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
+func (c *CodeCommitDownloader) GetComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
 	var (
 		nextToken *string
 		comments  []*base.Comment
 	)
 
 	for {
-		resp, err := c.codeCommitClient.GetCommentsForPullRequest(c.ctx, &codecommit.GetCommentsForPullRequestInput{
+		resp, err := c.codeCommitClient.GetCommentsForPullRequest(ctx, &codecommit.GetCommentsForPullRequestInput{
 			NextToken:     nextToken,
 			PullRequestId: util.ToPointer(strconv.FormatInt(commentable.GetForeignIndex(), 10)),
 		})
@@ -155,8 +148,8 @@ func (c *CodeCommitDownloader) GetComments(commentable base.Commentable) ([]*bas
 }
 
 // GetPullRequests returns pull requests according page and perPage
-func (c *CodeCommitDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
-	allPullRequestIDs, err := c.getAllPullRequestIDs()
+func (c *CodeCommitDownloader) GetPullRequests(ctx context.Context, page, perPage int) ([]*base.PullRequest, bool, error) {
+	allPullRequestIDs, err := c.getAllPullRequestIDs(ctx)
 	if err != nil {
 		return nil, false, err
 	}
@@ -170,7 +163,7 @@ func (c *CodeCommitDownloader) GetPullRequests(page, perPage int) ([]*base.PullR
 
 	prs := make([]*base.PullRequest, 0, len(batch))
 	for _, id := range batch {
-		output, err := c.codeCommitClient.GetPullRequest(c.ctx, &codecommit.GetPullRequestInput{
+		output, err := c.codeCommitClient.GetPullRequest(ctx, &codecommit.GetPullRequestInput{
 			PullRequestId: util.ToPointer(id),
 		})
 		if err != nil {
@@ -231,7 +224,7 @@ func (c *CodeCommitDownloader) FormatCloneURL(opts MigrateOptions, remoteAddr st
 	return u.String(), nil
 }
 
-func (c *CodeCommitDownloader) getAllPullRequestIDs() ([]string, error) {
+func (c *CodeCommitDownloader) getAllPullRequestIDs(ctx context.Context) ([]string, error) {
 	if len(c.allPullRequestIDs) > 0 {
 		return c.allPullRequestIDs, nil
 	}
@@ -242,7 +235,7 @@ func (c *CodeCommitDownloader) getAllPullRequestIDs() ([]string, error) {
 	)
 
 	for {
-		output, err := c.codeCommitClient.ListPullRequests(c.ctx, &codecommit.ListPullRequestsInput{
+		output, err := c.codeCommitClient.ListPullRequests(ctx, &codecommit.ListPullRequestsInput{
 			RepositoryName: util.ToPointer(c.repoName),
 			NextToken:      nextToken,
 		})
diff --git a/services/migrations/dump.go b/services/migrations/dump.go
index 07812002af..11efc18163 100644
--- a/services/migrations/dump.go
+++ b/services/migrations/dump.go
@@ -32,7 +32,6 @@ var _ base.Uploader = &RepositoryDumper{}
 
 // RepositoryDumper implements an Uploader to the local directory
 type RepositoryDumper struct {
-	ctx             context.Context
 	baseDir         string
 	repoOwner       string
 	repoName        string
@@ -56,7 +55,6 @@ func NewRepositoryDumper(ctx context.Context, baseDir, repoOwner, repoName strin
 		return nil, err
 	}
 	return &RepositoryDumper{
-		ctx:          ctx,
 		opts:         opts,
 		baseDir:      baseDir,
 		repoOwner:    repoOwner,
@@ -105,7 +103,7 @@ func (g *RepositoryDumper) setURLToken(remoteAddr string) (string, error) {
 }
 
 // CreateRepo creates a repository
-func (g *RepositoryDumper) CreateRepo(repo *base.Repository, opts base.MigrateOptions) error {
+func (g *RepositoryDumper) CreateRepo(ctx context.Context, repo *base.Repository, opts base.MigrateOptions) error {
 	f, err := os.Create(filepath.Join(g.baseDir, "repo.yml"))
 	if err != nil {
 		return err
@@ -149,7 +147,7 @@ func (g *RepositoryDumper) CreateRepo(repo *base.Repository, opts base.MigrateOp
 		return err
 	}
 
-	err = git.Clone(g.ctx, remoteAddr, repoPath, git.CloneRepoOptions{
+	err = git.Clone(ctx, remoteAddr, repoPath, git.CloneRepoOptions{
 		Mirror:        true,
 		Quiet:         true,
 		Timeout:       migrateTimeout,
@@ -158,19 +156,19 @@ func (g *RepositoryDumper) CreateRepo(repo *base.Repository, opts base.MigrateOp
 	if err != nil {
 		return fmt.Errorf("Clone: %w", err)
 	}
-	if err := git.WriteCommitGraph(g.ctx, repoPath); err != nil {
+	if err := git.WriteCommitGraph(ctx, repoPath); err != nil {
 		return err
 	}
 
 	if opts.Wiki {
 		wikiPath := g.wikiPath()
-		wikiRemotePath := repository.WikiRemoteURL(g.ctx, remoteAddr)
+		wikiRemotePath := repository.WikiRemoteURL(ctx, remoteAddr)
 		if len(wikiRemotePath) > 0 {
 			if err := os.MkdirAll(wikiPath, os.ModePerm); err != nil {
 				return fmt.Errorf("Failed to remove %s: %w", wikiPath, err)
 			}
 
-			if err := git.Clone(g.ctx, wikiRemotePath, wikiPath, git.CloneRepoOptions{
+			if err := git.Clone(ctx, wikiRemotePath, wikiPath, git.CloneRepoOptions{
 				Mirror:        true,
 				Quiet:         true,
 				Timeout:       migrateTimeout,
@@ -181,13 +179,13 @@ func (g *RepositoryDumper) CreateRepo(repo *base.Repository, opts base.MigrateOp
 				if err := os.RemoveAll(wikiPath); err != nil {
 					return fmt.Errorf("Failed to remove %s: %w", wikiPath, err)
 				}
-			} else if err := git.WriteCommitGraph(g.ctx, wikiPath); err != nil {
+			} else if err := git.WriteCommitGraph(ctx, wikiPath); err != nil {
 				return err
 			}
 		}
 	}
 
-	g.gitRepo, err = git.OpenRepository(g.ctx, g.gitPath())
+	g.gitRepo, err = git.OpenRepository(ctx, g.gitPath())
 	return err
 }
 
@@ -220,7 +218,7 @@ func (g *RepositoryDumper) Close() {
 }
 
 // CreateTopics creates topics
-func (g *RepositoryDumper) CreateTopics(topics ...string) error {
+func (g *RepositoryDumper) CreateTopics(_ context.Context, topics ...string) error {
 	f, err := os.Create(filepath.Join(g.baseDir, "topic.yml"))
 	if err != nil {
 		return err
@@ -242,7 +240,7 @@ func (g *RepositoryDumper) CreateTopics(topics ...string) error {
 }
 
 // CreateMilestones creates milestones
-func (g *RepositoryDumper) CreateMilestones(milestones ...*base.Milestone) error {
+func (g *RepositoryDumper) CreateMilestones(_ context.Context, milestones ...*base.Milestone) error {
 	var err error
 	if g.milestoneFile == nil {
 		g.milestoneFile, err = os.Create(filepath.Join(g.baseDir, "milestone.yml"))
@@ -264,7 +262,7 @@ func (g *RepositoryDumper) CreateMilestones(milestones ...*base.Milestone) error
 }
 
 // CreateLabels creates labels
-func (g *RepositoryDumper) CreateLabels(labels ...*base.Label) error {
+func (g *RepositoryDumper) CreateLabels(_ context.Context, labels ...*base.Label) error {
 	var err error
 	if g.labelFile == nil {
 		g.labelFile, err = os.Create(filepath.Join(g.baseDir, "label.yml"))
@@ -286,7 +284,7 @@ func (g *RepositoryDumper) CreateLabels(labels ...*base.Label) error {
 }
 
 // CreateReleases creates releases
-func (g *RepositoryDumper) CreateReleases(releases ...*base.Release) error {
+func (g *RepositoryDumper) CreateReleases(_ context.Context, releases ...*base.Release) error {
 	if g.opts.ReleaseAssets {
 		for _, release := range releases {
 			attachDir := filepath.Join("release_assets", release.TagName)
@@ -354,12 +352,12 @@ func (g *RepositoryDumper) CreateReleases(releases ...*base.Release) error {
 }
 
 // SyncTags syncs releases with tags in the database
-func (g *RepositoryDumper) SyncTags() error {
+func (g *RepositoryDumper) SyncTags(ctx context.Context) error {
 	return nil
 }
 
 // CreateIssues creates issues
-func (g *RepositoryDumper) CreateIssues(issues ...*base.Issue) error {
+func (g *RepositoryDumper) CreateIssues(_ context.Context, issues ...*base.Issue) error {
 	var err error
 	if g.issueFile == nil {
 		g.issueFile, err = os.Create(filepath.Join(g.baseDir, "issue.yml"))
@@ -412,7 +410,7 @@ func (g *RepositoryDumper) encodeItems(number int64, items []any, dir string, it
 }
 
 // CreateComments creates comments of issues
-func (g *RepositoryDumper) CreateComments(comments ...*base.Comment) error {
+func (g *RepositoryDumper) CreateComments(_ context.Context, comments ...*base.Comment) error {
 	commentsMap := make(map[int64][]any, len(comments))
 	for _, comment := range comments {
 		commentsMap[comment.IssueIndex] = append(commentsMap[comment.IssueIndex], comment)
@@ -421,7 +419,7 @@ func (g *RepositoryDumper) CreateComments(comments ...*base.Comment) error {
 	return g.createItems(g.commentDir(), g.commentFiles, commentsMap)
 }
 
-func (g *RepositoryDumper) handlePullRequest(pr *base.PullRequest) error {
+func (g *RepositoryDumper) handlePullRequest(ctx context.Context, pr *base.PullRequest) error {
 	// SECURITY: this pr must have been ensured safe
 	if !pr.EnsuredSafe {
 		log.Error("PR #%d in %s/%s has not been checked for safety ... We will ignore this.", pr.Number, g.repoOwner, g.repoName)
@@ -490,7 +488,7 @@ func (g *RepositoryDumper) handlePullRequest(pr *base.PullRequest) error {
 	if pr.Head.CloneURL == "" || pr.Head.Ref == "" {
 		// Set head information if pr.Head.SHA is available
 		if pr.Head.SHA != "" {
-			_, _, err = git.NewCommand(g.ctx, "update-ref", "--no-deref").AddDynamicArguments(pr.GetGitRefName(), pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.gitPath()})
+			_, _, err = git.NewCommand(ctx, "update-ref", "--no-deref").AddDynamicArguments(pr.GetGitRefName(), pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.gitPath()})
 			if err != nil {
 				log.Error("PR #%d in %s/%s unable to update-ref for pr HEAD: %v", pr.Number, g.repoOwner, g.repoName, err)
 			}
@@ -520,7 +518,7 @@ func (g *RepositoryDumper) handlePullRequest(pr *base.PullRequest) error {
 	if !ok {
 		// Set head information if pr.Head.SHA is available
 		if pr.Head.SHA != "" {
-			_, _, err = git.NewCommand(g.ctx, "update-ref", "--no-deref").AddDynamicArguments(pr.GetGitRefName(), pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.gitPath()})
+			_, _, err = git.NewCommand(ctx, "update-ref", "--no-deref").AddDynamicArguments(pr.GetGitRefName(), pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.gitPath()})
 			if err != nil {
 				log.Error("PR #%d in %s/%s unable to update-ref for pr HEAD: %v", pr.Number, g.repoOwner, g.repoName, err)
 			}
@@ -555,7 +553,7 @@ func (g *RepositoryDumper) handlePullRequest(pr *base.PullRequest) error {
 			fetchArg = git.BranchPrefix + fetchArg
 		}
 
-		_, _, err = git.NewCommand(g.ctx, "fetch", "--no-tags").AddDashesAndList(remote, fetchArg).RunStdString(&git.RunOpts{Dir: g.gitPath()})
+		_, _, err = git.NewCommand(ctx, "fetch", "--no-tags").AddDashesAndList(remote, fetchArg).RunStdString(&git.RunOpts{Dir: g.gitPath()})
 		if err != nil {
 			log.Error("Fetch branch from %s failed: %v", pr.Head.CloneURL, err)
 			// We need to continue here so that the Head.Ref is reset and we attempt to set the gitref for the PR
@@ -579,7 +577,7 @@ func (g *RepositoryDumper) handlePullRequest(pr *base.PullRequest) error {
 		pr.Head.SHA = headSha
 	}
 	if pr.Head.SHA != "" {
-		_, _, err = git.NewCommand(g.ctx, "update-ref", "--no-deref").AddDynamicArguments(pr.GetGitRefName(), pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.gitPath()})
+		_, _, err = git.NewCommand(ctx, "update-ref", "--no-deref").AddDynamicArguments(pr.GetGitRefName(), pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.gitPath()})
 		if err != nil {
 			log.Error("unable to set %s as the local head for PR #%d from %s in %s/%s. Error: %v", pr.Head.SHA, pr.Number, pr.Head.Ref, g.repoOwner, g.repoName, err)
 		}
@@ -589,7 +587,7 @@ func (g *RepositoryDumper) handlePullRequest(pr *base.PullRequest) error {
 }
 
 // CreatePullRequests creates pull requests
-func (g *RepositoryDumper) CreatePullRequests(prs ...*base.PullRequest) error {
+func (g *RepositoryDumper) CreatePullRequests(ctx context.Context, prs ...*base.PullRequest) error {
 	var err error
 	if g.pullrequestFile == nil {
 		if err := os.MkdirAll(g.baseDir, os.ModePerm); err != nil {
@@ -607,7 +605,7 @@ func (g *RepositoryDumper) CreatePullRequests(prs ...*base.PullRequest) error {
 	count := 0
 	for i := 0; i < len(prs); i++ {
 		pr := prs[i]
-		if err := g.handlePullRequest(pr); err != nil {
+		if err := g.handlePullRequest(ctx, pr); err != nil {
 			log.Error("PR #%d in %s/%s failed - skipping", pr.Number, g.repoOwner, g.repoName, err)
 			continue
 		}
@@ -620,7 +618,7 @@ func (g *RepositoryDumper) CreatePullRequests(prs ...*base.PullRequest) error {
 }
 
 // CreateReviews create pull request reviews
-func (g *RepositoryDumper) CreateReviews(reviews ...*base.Review) error {
+func (g *RepositoryDumper) CreateReviews(_ context.Context, reviews ...*base.Review) error {
 	reviewsMap := make(map[int64][]any, len(reviews))
 	for _, review := range reviews {
 		reviewsMap[review.IssueIndex] = append(reviewsMap[review.IssueIndex], review)
@@ -636,7 +634,7 @@ func (g *RepositoryDumper) Rollback() error {
 }
 
 // Finish when migrating succeed, this will update something.
-func (g *RepositoryDumper) Finish() error {
+func (g *RepositoryDumper) Finish(_ context.Context) error {
 	return nil
 }
 
diff --git a/services/migrations/git.go b/services/migrations/git.go
index 22ffd5e765..1ed99499a1 100644
--- a/services/migrations/git.go
+++ b/services/migrations/git.go
@@ -28,12 +28,8 @@ func NewPlainGitDownloader(ownerName, repoName, remoteURL string) *PlainGitDownl
 	}
 }
 
-// SetContext set context
-func (g *PlainGitDownloader) SetContext(ctx context.Context) {
-}
-
 // GetRepoInfo returns a repository information
-func (g *PlainGitDownloader) GetRepoInfo() (*base.Repository, error) {
+func (g *PlainGitDownloader) GetRepoInfo(_ context.Context) (*base.Repository, error) {
 	// convert github repo to stand Repo
 	return &base.Repository{
 		Owner:    g.ownerName,
@@ -43,6 +39,6 @@ func (g *PlainGitDownloader) GetRepoInfo() (*base.Repository, error) {
 }
 
 // GetTopics return empty string slice
-func (g PlainGitDownloader) GetTopics() ([]string, error) {
+func (g PlainGitDownloader) GetTopics(_ context.Context) ([]string, error) {
 	return []string{}, nil
 }
diff --git a/services/migrations/gitea_downloader.go b/services/migrations/gitea_downloader.go
index 272bf02e11..f92f318293 100644
--- a/services/migrations/gitea_downloader.go
+++ b/services/migrations/gitea_downloader.go
@@ -67,7 +67,6 @@ func (f *GiteaDownloaderFactory) GitServiceType() structs.GitServiceType {
 // GiteaDownloader implements a Downloader interface to get repository information's
 type GiteaDownloader struct {
 	base.NullDownloader
-	ctx        context.Context
 	client     *gitea_sdk.Client
 	baseURL    string
 	repoOwner  string
@@ -114,7 +113,6 @@ func NewGiteaDownloader(ctx context.Context, baseURL, repoPath, username, passwo
 	}
 
 	return &GiteaDownloader{
-		ctx:        ctx,
 		client:     giteaClient,
 		baseURL:    baseURL,
 		repoOwner:  path[0],
@@ -124,11 +122,6 @@ func NewGiteaDownloader(ctx context.Context, baseURL, repoPath, username, passwo
 	}, nil
 }
 
-// SetContext set context
-func (g *GiteaDownloader) SetContext(ctx context.Context) {
-	g.ctx = ctx
-}
-
 // String implements Stringer
 func (g *GiteaDownloader) String() string {
 	return fmt.Sprintf("migration from gitea server %s %s/%s", g.baseURL, g.repoOwner, g.repoName)
@@ -142,7 +135,7 @@ func (g *GiteaDownloader) LogString() string {
 }
 
 // GetRepoInfo returns a repository information
-func (g *GiteaDownloader) GetRepoInfo() (*base.Repository, error) {
+func (g *GiteaDownloader) GetRepoInfo(_ context.Context) (*base.Repository, error) {
 	if g == nil {
 		return nil, errors.New("error: GiteaDownloader is nil")
 	}
@@ -164,19 +157,19 @@ func (g *GiteaDownloader) GetRepoInfo() (*base.Repository, error) {
 }
 
 // GetTopics return gitea topics
-func (g *GiteaDownloader) GetTopics() ([]string, error) {
+func (g *GiteaDownloader) GetTopics(_ context.Context) ([]string, error) {
 	topics, _, err := g.client.ListRepoTopics(g.repoOwner, g.repoName, gitea_sdk.ListRepoTopicsOptions{})
 	return topics, err
 }
 
 // GetMilestones returns milestones
-func (g *GiteaDownloader) GetMilestones() ([]*base.Milestone, error) {
+func (g *GiteaDownloader) GetMilestones(ctx context.Context) ([]*base.Milestone, error) {
 	milestones := make([]*base.Milestone, 0, g.maxPerPage)
 
 	for i := 1; ; i++ {
 		// make sure gitea can shutdown gracefully
 		select {
-		case <-g.ctx.Done():
+		case <-ctx.Done():
 			return nil, nil
 		default:
 		}
@@ -235,13 +228,13 @@ func (g *GiteaDownloader) convertGiteaLabel(label *gitea_sdk.Label) *base.Label
 }
 
 // GetLabels returns labels
-func (g *GiteaDownloader) GetLabels() ([]*base.Label, error) {
+func (g *GiteaDownloader) GetLabels(ctx context.Context) ([]*base.Label, error) {
 	labels := make([]*base.Label, 0, g.maxPerPage)
 
 	for i := 1; ; i++ {
 		// make sure gitea can shutdown gracefully
 		select {
-		case <-g.ctx.Done():
+		case <-ctx.Done():
 			return nil, nil
 		default:
 		}
@@ -323,13 +316,13 @@ func (g *GiteaDownloader) convertGiteaRelease(rel *gitea_sdk.Release) *base.Rele
 }
 
 // GetReleases returns releases
-func (g *GiteaDownloader) GetReleases() ([]*base.Release, error) {
+func (g *GiteaDownloader) GetReleases(ctx context.Context) ([]*base.Release, error) {
 	releases := make([]*base.Release, 0, g.maxPerPage)
 
 	for i := 1; ; i++ {
 		// make sure gitea can shutdown gracefully
 		select {
-		case <-g.ctx.Done():
+		case <-ctx.Done():
 			return nil, nil
 		default:
 		}
@@ -395,7 +388,7 @@ func (g *GiteaDownloader) getCommentReactions(commentID int64) ([]*base.Reaction
 }
 
 // GetIssues returns issues according start and limit
-func (g *GiteaDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
+func (g *GiteaDownloader) GetIssues(_ context.Context, page, perPage int) ([]*base.Issue, bool, error) {
 	if perPage > g.maxPerPage {
 		perPage = g.maxPerPage
 	}
@@ -458,13 +451,13 @@ func (g *GiteaDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, err
 }
 
 // GetComments returns comments according issueNumber
-func (g *GiteaDownloader) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
+func (g *GiteaDownloader) GetComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
 	allComments := make([]*base.Comment, 0, g.maxPerPage)
 
 	for i := 1; ; i++ {
 		// make sure gitea can shutdown gracefully
 		select {
-		case <-g.ctx.Done():
+		case <-ctx.Done():
 			return nil, false, nil
 		default:
 		}
@@ -504,7 +497,7 @@ func (g *GiteaDownloader) GetComments(commentable base.Commentable) ([]*base.Com
 }
 
 // GetPullRequests returns pull requests according page and perPage
-func (g *GiteaDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
+func (g *GiteaDownloader) GetPullRequests(_ context.Context, page, perPage int) ([]*base.PullRequest, bool, error) {
 	if perPage > g.maxPerPage {
 		perPage = g.maxPerPage
 	}
@@ -624,7 +617,7 @@ func (g *GiteaDownloader) GetPullRequests(page, perPage int) ([]*base.PullReques
 }
 
 // GetReviews returns pull requests review
-func (g *GiteaDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Review, error) {
+func (g *GiteaDownloader) GetReviews(ctx context.Context, reviewable base.Reviewable) ([]*base.Review, error) {
 	if err := g.client.CheckServerVersionConstraint(">=1.12"); err != nil {
 		log.Info("GiteaDownloader: instance to old, skip GetReviews")
 		return nil, nil
@@ -635,7 +628,7 @@ func (g *GiteaDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Review
 	for i := 1; ; i++ {
 		// make sure gitea can shutdown gracefully
 		select {
-		case <-g.ctx.Done():
+		case <-ctx.Done():
 			return nil, nil
 		default:
 		}
diff --git a/services/migrations/gitea_downloader_test.go b/services/migrations/gitea_downloader_test.go
index 6f6ef99d96..3dccc4017e 100644
--- a/services/migrations/gitea_downloader_test.go
+++ b/services/migrations/gitea_downloader_test.go
@@ -28,12 +28,12 @@ func TestGiteaDownloadRepo(t *testing.T) {
 	if err != nil || resp.StatusCode != http.StatusOK {
 		t.Skipf("Can't reach https://gitea.com, skipping %s", t.Name())
 	}
-
-	downloader, err := NewGiteaDownloader(context.Background(), "https://gitea.com", "gitea/test_repo", "", "", giteaToken)
+	ctx := context.Background()
+	downloader, err := NewGiteaDownloader(ctx, "https://gitea.com", "gitea/test_repo", "", "", giteaToken)
 	require.NoError(t, err, "NewGiteaDownloader error occur")
 	require.NotNil(t, downloader, "NewGiteaDownloader is nil")
 
-	repo, err := downloader.GetRepoInfo()
+	repo, err := downloader.GetRepoInfo(ctx)
 	assert.NoError(t, err)
 	assertRepositoryEqual(t, &base.Repository{
 		Name:          "test_repo",
@@ -45,12 +45,12 @@ func TestGiteaDownloadRepo(t *testing.T) {
 		DefaultBranch: "master",
 	}, repo)
 
-	topics, err := downloader.GetTopics()
+	topics, err := downloader.GetTopics(ctx)
 	assert.NoError(t, err)
 	sort.Strings(topics)
 	assert.EqualValues(t, []string{"ci", "gitea", "migration", "test"}, topics)
 
-	labels, err := downloader.GetLabels()
+	labels, err := downloader.GetLabels(ctx)
 	assert.NoError(t, err)
 	assertLabelsEqual(t, []*base.Label{
 		{
@@ -80,7 +80,7 @@ func TestGiteaDownloadRepo(t *testing.T) {
 		},
 	}, labels)
 
-	milestones, err := downloader.GetMilestones()
+	milestones, err := downloader.GetMilestones(ctx)
 	assert.NoError(t, err)
 	assertMilestonesEqual(t, []*base.Milestone{
 		{
@@ -100,7 +100,7 @@ func TestGiteaDownloadRepo(t *testing.T) {
 		},
 	}, milestones)
 
-	releases, err := downloader.GetReleases()
+	releases, err := downloader.GetReleases(ctx)
 	assert.NoError(t, err)
 	assertReleasesEqual(t, []*base.Release{
 		{
@@ -131,13 +131,13 @@ func TestGiteaDownloadRepo(t *testing.T) {
 		},
 	}, releases)
 
-	issues, isEnd, err := downloader.GetIssues(1, 50)
+	issues, isEnd, err := downloader.GetIssues(ctx, 1, 50)
 	assert.NoError(t, err)
 	assert.True(t, isEnd)
 	assert.Len(t, issues, 7)
 	assert.EqualValues(t, "open", issues[0].State)
 
-	issues, isEnd, err = downloader.GetIssues(3, 2)
+	issues, isEnd, err = downloader.GetIssues(ctx, 3, 2)
 	assert.NoError(t, err)
 	assert.False(t, isEnd)
 
@@ -194,7 +194,7 @@ func TestGiteaDownloadRepo(t *testing.T) {
 		},
 	}, issues)
 
-	comments, _, err := downloader.GetComments(&base.Issue{Number: 4, ForeignIndex: 4})
+	comments, _, err := downloader.GetComments(ctx, &base.Issue{Number: 4, ForeignIndex: 4})
 	assert.NoError(t, err)
 	assertCommentsEqual(t, []*base.Comment{
 		{
@@ -217,11 +217,11 @@ func TestGiteaDownloadRepo(t *testing.T) {
 		},
 	}, comments)
 
-	prs, isEnd, err := downloader.GetPullRequests(1, 50)
+	prs, isEnd, err := downloader.GetPullRequests(ctx, 1, 50)
 	assert.NoError(t, err)
 	assert.True(t, isEnd)
 	assert.Len(t, prs, 6)
-	prs, isEnd, err = downloader.GetPullRequests(1, 3)
+	prs, isEnd, err = downloader.GetPullRequests(ctx, 1, 3)
 	assert.NoError(t, err)
 	assert.False(t, isEnd)
 	assert.Len(t, prs, 3)
@@ -259,7 +259,7 @@ func TestGiteaDownloadRepo(t *testing.T) {
 		PatchURL:       "https://gitea.com/gitea/test_repo/pulls/12.patch",
 	}, prs[1])
 
-	reviews, err := downloader.GetReviews(&base.Issue{Number: 7, ForeignIndex: 7})
+	reviews, err := downloader.GetReviews(ctx, &base.Issue{Number: 7, ForeignIndex: 7})
 	assert.NoError(t, err)
 	assertReviewsEqual(t, []*base.Review{
 		{
diff --git a/services/migrations/gitea_uploader.go b/services/migrations/gitea_uploader.go
index 9e06b77b66..eb16d6cb42 100644
--- a/services/migrations/gitea_uploader.go
+++ b/services/migrations/gitea_uploader.go
@@ -41,7 +41,6 @@ var _ base.Uploader = &GiteaLocalUploader{}
 
 // GiteaLocalUploader implements an Uploader to gitea sites
 type GiteaLocalUploader struct {
-	ctx            context.Context
 	doer           *user_model.User
 	repoOwner      string
 	repoName       string
@@ -58,9 +57,8 @@ type GiteaLocalUploader struct {
 }
 
 // NewGiteaLocalUploader creates an gitea Uploader via gitea API v1
-func NewGiteaLocalUploader(ctx context.Context, doer *user_model.User, repoOwner, repoName string) *GiteaLocalUploader {
+func NewGiteaLocalUploader(_ context.Context, doer *user_model.User, repoOwner, repoName string) *GiteaLocalUploader {
 	return &GiteaLocalUploader{
-		ctx:         ctx,
 		doer:        doer,
 		repoOwner:   repoOwner,
 		repoName:    repoName,
@@ -93,15 +91,15 @@ func (g *GiteaLocalUploader) MaxBatchInsertSize(tp string) int {
 }
 
 // CreateRepo creates a repository
-func (g *GiteaLocalUploader) CreateRepo(repo *base.Repository, opts base.MigrateOptions) error {
-	owner, err := user_model.GetUserByName(g.ctx, g.repoOwner)
+func (g *GiteaLocalUploader) CreateRepo(ctx context.Context, repo *base.Repository, opts base.MigrateOptions) error {
+	owner, err := user_model.GetUserByName(ctx, g.repoOwner)
 	if err != nil {
 		return err
 	}
 
 	var r *repo_model.Repository
 	if opts.MigrateToRepoID <= 0 {
-		r, err = repo_service.CreateRepositoryDirectly(g.ctx, g.doer, owner, repo_service.CreateRepoOptions{
+		r, err = repo_service.CreateRepositoryDirectly(ctx, g.doer, owner, repo_service.CreateRepoOptions{
 			Name:           g.repoName,
 			Description:    repo.Description,
 			OriginalURL:    repo.OriginalURL,
@@ -111,7 +109,7 @@ func (g *GiteaLocalUploader) CreateRepo(repo *base.Repository, opts base.Migrate
 			Status:         repo_model.RepositoryBeingMigrated,
 		})
 	} else {
-		r, err = repo_model.GetRepositoryByID(g.ctx, opts.MigrateToRepoID)
+		r, err = repo_model.GetRepositoryByID(ctx, opts.MigrateToRepoID)
 	}
 	if err != nil {
 		return err
@@ -119,7 +117,7 @@ func (g *GiteaLocalUploader) CreateRepo(repo *base.Repository, opts base.Migrate
 	r.DefaultBranch = repo.DefaultBranch
 	r.Description = repo.Description
 
-	r, err = repo_service.MigrateRepositoryGitData(g.ctx, owner, r, base.MigrateOptions{
+	r, err = repo_service.MigrateRepositoryGitData(ctx, owner, r, base.MigrateOptions{
 		RepoName:       g.repoName,
 		Description:    repo.Description,
 		OriginalURL:    repo.OriginalURL,
@@ -139,7 +137,7 @@ func (g *GiteaLocalUploader) CreateRepo(repo *base.Repository, opts base.Migrate
 	if err != nil {
 		return err
 	}
-	g.gitRepo, err = gitrepo.OpenRepository(g.ctx, g.repo)
+	g.gitRepo, err = gitrepo.OpenRepository(ctx, g.repo)
 	if err != nil {
 		return err
 	}
@@ -150,7 +148,7 @@ func (g *GiteaLocalUploader) CreateRepo(repo *base.Repository, opts base.Migrate
 		return err
 	}
 	g.repo.ObjectFormatName = objectFormat.Name()
-	return repo_model.UpdateRepositoryCols(g.ctx, g.repo, "object_format_name")
+	return repo_model.UpdateRepositoryCols(ctx, g.repo, "object_format_name")
 }
 
 // Close closes this uploader
@@ -161,7 +159,7 @@ func (g *GiteaLocalUploader) Close() {
 }
 
 // CreateTopics creates topics
-func (g *GiteaLocalUploader) CreateTopics(topics ...string) error {
+func (g *GiteaLocalUploader) CreateTopics(ctx context.Context, topics ...string) error {
 	// Ignore topics too long for the db
 	c := 0
 	for _, topic := range topics {
@@ -173,11 +171,11 @@ func (g *GiteaLocalUploader) CreateTopics(topics ...string) error {
 		c++
 	}
 	topics = topics[:c]
-	return repo_model.SaveTopics(g.ctx, g.repo.ID, topics...)
+	return repo_model.SaveTopics(ctx, g.repo.ID, topics...)
 }
 
 // CreateMilestones creates milestones
-func (g *GiteaLocalUploader) CreateMilestones(milestones ...*base.Milestone) error {
+func (g *GiteaLocalUploader) CreateMilestones(ctx context.Context, milestones ...*base.Milestone) error {
 	mss := make([]*issues_model.Milestone, 0, len(milestones))
 	for _, milestone := range milestones {
 		var deadline timeutil.TimeStamp
@@ -216,7 +214,7 @@ func (g *GiteaLocalUploader) CreateMilestones(milestones ...*base.Milestone) err
 		mss = append(mss, &ms)
 	}
 
-	err := issues_model.InsertMilestones(g.ctx, mss...)
+	err := issues_model.InsertMilestones(ctx, mss...)
 	if err != nil {
 		return err
 	}
@@ -228,7 +226,7 @@ func (g *GiteaLocalUploader) CreateMilestones(milestones ...*base.Milestone) err
 }
 
 // CreateLabels creates labels
-func (g *GiteaLocalUploader) CreateLabels(labels ...*base.Label) error {
+func (g *GiteaLocalUploader) CreateLabels(ctx context.Context, labels ...*base.Label) error {
 	lbs := make([]*issues_model.Label, 0, len(labels))
 	for _, l := range labels {
 		if color, err := label.NormalizeColor(l.Color); err != nil {
@@ -247,7 +245,7 @@ func (g *GiteaLocalUploader) CreateLabels(labels ...*base.Label) error {
 		})
 	}
 
-	err := issues_model.NewLabels(g.ctx, lbs...)
+	err := issues_model.NewLabels(ctx, lbs...)
 	if err != nil {
 		return err
 	}
@@ -258,7 +256,7 @@ func (g *GiteaLocalUploader) CreateLabels(labels ...*base.Label) error {
 }
 
 // CreateReleases creates releases
-func (g *GiteaLocalUploader) CreateReleases(releases ...*base.Release) error {
+func (g *GiteaLocalUploader) CreateReleases(ctx context.Context, releases ...*base.Release) error {
 	rels := make([]*repo_model.Release, 0, len(releases))
 	for _, release := range releases {
 		if release.Created.IsZero() {
@@ -292,7 +290,7 @@ func (g *GiteaLocalUploader) CreateReleases(releases ...*base.Release) error {
 			CreatedUnix:  timeutil.TimeStamp(release.Created.Unix()),
 		}
 
-		if err := g.remapUser(release, &rel); err != nil {
+		if err := g.remapUser(ctx, release, &rel); err != nil {
 			return err
 		}
 
@@ -361,16 +359,16 @@ func (g *GiteaLocalUploader) CreateReleases(releases ...*base.Release) error {
 		rels = append(rels, &rel)
 	}
 
-	return repo_model.InsertReleases(g.ctx, rels...)
+	return repo_model.InsertReleases(ctx, rels...)
 }
 
 // SyncTags syncs releases with tags in the database
-func (g *GiteaLocalUploader) SyncTags() error {
-	return repo_module.SyncReleasesWithTags(g.ctx, g.repo, g.gitRepo)
+func (g *GiteaLocalUploader) SyncTags(ctx context.Context) error {
+	return repo_module.SyncReleasesWithTags(ctx, g.repo, g.gitRepo)
 }
 
 // CreateIssues creates issues
-func (g *GiteaLocalUploader) CreateIssues(issues ...*base.Issue) error {
+func (g *GiteaLocalUploader) CreateIssues(ctx context.Context, issues ...*base.Issue) error {
 	iss := make([]*issues_model.Issue, 0, len(issues))
 	for _, issue := range issues {
 		var labels []*issues_model.Label
@@ -419,7 +417,7 @@ func (g *GiteaLocalUploader) CreateIssues(issues ...*base.Issue) error {
 			UpdatedUnix: timeutil.TimeStamp(issue.Updated.Unix()),
 		}
 
-		if err := g.remapUser(issue, &is); err != nil {
+		if err := g.remapUser(ctx, issue, &is); err != nil {
 			return err
 		}
 
@@ -432,7 +430,7 @@ func (g *GiteaLocalUploader) CreateIssues(issues ...*base.Issue) error {
 				Type:        reaction.Content,
 				CreatedUnix: timeutil.TimeStampNow(),
 			}
-			if err := g.remapUser(reaction, &res); err != nil {
+			if err := g.remapUser(ctx, reaction, &res); err != nil {
 				return err
 			}
 			is.Reactions = append(is.Reactions, &res)
@@ -441,7 +439,7 @@ func (g *GiteaLocalUploader) CreateIssues(issues ...*base.Issue) error {
 	}
 
 	if len(iss) > 0 {
-		if err := issues_model.InsertIssues(g.ctx, iss...); err != nil {
+		if err := issues_model.InsertIssues(ctx, iss...); err != nil {
 			return err
 		}
 
@@ -454,7 +452,7 @@ func (g *GiteaLocalUploader) CreateIssues(issues ...*base.Issue) error {
 }
 
 // CreateComments creates comments of issues
-func (g *GiteaLocalUploader) CreateComments(comments ...*base.Comment) error {
+func (g *GiteaLocalUploader) CreateComments(ctx context.Context, comments ...*base.Comment) error {
 	cms := make([]*issues_model.Comment, 0, len(comments))
 	for _, comment := range comments {
 		var issue *issues_model.Issue
@@ -513,7 +511,7 @@ func (g *GiteaLocalUploader) CreateComments(comments ...*base.Comment) error {
 		default:
 		}
 
-		if err := g.remapUser(comment, &cm); err != nil {
+		if err := g.remapUser(ctx, comment, &cm); err != nil {
 			return err
 		}
 
@@ -523,7 +521,7 @@ func (g *GiteaLocalUploader) CreateComments(comments ...*base.Comment) error {
 				Type:        reaction.Content,
 				CreatedUnix: timeutil.TimeStampNow(),
 			}
-			if err := g.remapUser(reaction, &res); err != nil {
+			if err := g.remapUser(ctx, reaction, &res); err != nil {
 				return err
 			}
 			cm.Reactions = append(cm.Reactions, &res)
@@ -535,35 +533,35 @@ func (g *GiteaLocalUploader) CreateComments(comments ...*base.Comment) error {
 	if len(cms) == 0 {
 		return nil
 	}
-	return issues_model.InsertIssueComments(g.ctx, cms)
+	return issues_model.InsertIssueComments(ctx, cms)
 }
 
 // CreatePullRequests creates pull requests
-func (g *GiteaLocalUploader) CreatePullRequests(prs ...*base.PullRequest) error {
+func (g *GiteaLocalUploader) CreatePullRequests(ctx context.Context, prs ...*base.PullRequest) error {
 	gprs := make([]*issues_model.PullRequest, 0, len(prs))
 	for _, pr := range prs {
-		gpr, err := g.newPullRequest(pr)
+		gpr, err := g.newPullRequest(ctx, pr)
 		if err != nil {
 			return err
 		}
 
-		if err := g.remapUser(pr, gpr.Issue); err != nil {
+		if err := g.remapUser(ctx, pr, gpr.Issue); err != nil {
 			return err
 		}
 
 		gprs = append(gprs, gpr)
 	}
-	if err := issues_model.InsertPullRequests(g.ctx, gprs...); err != nil {
+	if err := issues_model.InsertPullRequests(ctx, gprs...); err != nil {
 		return err
 	}
 	for _, pr := range gprs {
 		g.issues[pr.Issue.Index] = pr.Issue
-		pull.AddToTaskQueue(g.ctx, pr)
+		pull.AddToTaskQueue(ctx, pr)
 	}
 	return nil
 }
 
-func (g *GiteaLocalUploader) updateGitForPullRequest(pr *base.PullRequest) (head string, err error) {
+func (g *GiteaLocalUploader) updateGitForPullRequest(ctx context.Context, pr *base.PullRequest) (head string, err error) {
 	// SECURITY: this pr must have been must have been ensured safe
 	if !pr.EnsuredSafe {
 		log.Error("PR #%d in %s/%s has not been checked for safety.", pr.Number, g.repoOwner, g.repoName)
@@ -664,7 +662,7 @@ func (g *GiteaLocalUploader) updateGitForPullRequest(pr *base.PullRequest) (head
 				fetchArg = git.BranchPrefix + fetchArg
 			}
 
-			_, _, err = git.NewCommand(g.ctx, "fetch", "--no-tags").AddDashesAndList(remote, fetchArg).RunStdString(&git.RunOpts{Dir: g.repo.RepoPath()})
+			_, _, err = git.NewCommand(ctx, "fetch", "--no-tags").AddDashesAndList(remote, fetchArg).RunStdString(&git.RunOpts{Dir: g.repo.RepoPath()})
 			if err != nil {
 				log.Error("Fetch branch from %s failed: %v", pr.Head.CloneURL, err)
 				return head, nil
@@ -683,7 +681,7 @@ func (g *GiteaLocalUploader) updateGitForPullRequest(pr *base.PullRequest) (head
 			pr.Head.SHA = headSha
 		}
 
-		_, _, err = git.NewCommand(g.ctx, "update-ref", "--no-deref").AddDynamicArguments(pr.GetGitRefName(), pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.repo.RepoPath()})
+		_, _, err = git.NewCommand(ctx, "update-ref", "--no-deref").AddDynamicArguments(pr.GetGitRefName(), pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.repo.RepoPath()})
 		if err != nil {
 			return "", err
 		}
@@ -700,13 +698,13 @@ func (g *GiteaLocalUploader) updateGitForPullRequest(pr *base.PullRequest) (head
 		// The SHA is empty
 		log.Warn("Empty reference, no pull head for PR #%d in %s/%s", pr.Number, g.repoOwner, g.repoName)
 	} else {
-		_, _, err = git.NewCommand(g.ctx, "rev-list", "--quiet", "-1").AddDynamicArguments(pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.repo.RepoPath()})
+		_, _, err = git.NewCommand(ctx, "rev-list", "--quiet", "-1").AddDynamicArguments(pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.repo.RepoPath()})
 		if err != nil {
 			// Git update-ref remove bad references with a relative path
 			log.Warn("Deprecated local head %s for PR #%d in %s/%s, removing  %s", pr.Head.SHA, pr.Number, g.repoOwner, g.repoName, pr.GetGitRefName())
 		} else {
 			// set head information
-			_, _, err = git.NewCommand(g.ctx, "update-ref", "--no-deref").AddDynamicArguments(pr.GetGitRefName(), pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.repo.RepoPath()})
+			_, _, err = git.NewCommand(ctx, "update-ref", "--no-deref").AddDynamicArguments(pr.GetGitRefName(), pr.Head.SHA).RunStdString(&git.RunOpts{Dir: g.repo.RepoPath()})
 			if err != nil {
 				log.Error("unable to set %s as the local head for PR #%d from %s in %s/%s. Error: %v", pr.Head.SHA, pr.Number, pr.Head.Ref, g.repoOwner, g.repoName, err)
 			}
@@ -716,7 +714,7 @@ func (g *GiteaLocalUploader) updateGitForPullRequest(pr *base.PullRequest) (head
 	return head, nil
 }
 
-func (g *GiteaLocalUploader) newPullRequest(pr *base.PullRequest) (*issues_model.PullRequest, error) {
+func (g *GiteaLocalUploader) newPullRequest(ctx context.Context, pr *base.PullRequest) (*issues_model.PullRequest, error) {
 	var labels []*issues_model.Label
 	for _, label := range pr.Labels {
 		lb, ok := g.labels[label.Name]
@@ -727,7 +725,7 @@ func (g *GiteaLocalUploader) newPullRequest(pr *base.PullRequest) (*issues_model
 
 	milestoneID := g.milestones[pr.Milestone]
 
-	head, err := g.updateGitForPullRequest(pr)
+	head, err := g.updateGitForPullRequest(ctx, pr)
 	if err != nil {
 		return nil, fmt.Errorf("updateGitForPullRequest: %w", err)
 	}
@@ -779,7 +777,7 @@ func (g *GiteaLocalUploader) newPullRequest(pr *base.PullRequest) (*issues_model
 		UpdatedUnix: timeutil.TimeStamp(pr.Updated.Unix()),
 	}
 
-	if err := g.remapUser(pr, &issue); err != nil {
+	if err := g.remapUser(ctx, pr, &issue); err != nil {
 		return nil, err
 	}
 
@@ -789,7 +787,7 @@ func (g *GiteaLocalUploader) newPullRequest(pr *base.PullRequest) (*issues_model
 			Type:        reaction.Content,
 			CreatedUnix: timeutil.TimeStampNow(),
 		}
-		if err := g.remapUser(reaction, &res); err != nil {
+		if err := g.remapUser(ctx, reaction, &res); err != nil {
 			return nil, err
 		}
 		issue.Reactions = append(issue.Reactions, &res)
@@ -839,7 +837,7 @@ func convertReviewState(state string) issues_model.ReviewType {
 }
 
 // CreateReviews create pull request reviews of currently migrated issues
-func (g *GiteaLocalUploader) CreateReviews(reviews ...*base.Review) error {
+func (g *GiteaLocalUploader) CreateReviews(ctx context.Context, reviews ...*base.Review) error {
 	cms := make([]*issues_model.Review, 0, len(reviews))
 	for _, review := range reviews {
 		var issue *issues_model.Issue
@@ -860,7 +858,7 @@ func (g *GiteaLocalUploader) CreateReviews(reviews ...*base.Review) error {
 			UpdatedUnix: timeutil.TimeStamp(review.CreatedAt.Unix()),
 		}
 
-		if err := g.remapUser(review, &cm); err != nil {
+		if err := g.remapUser(ctx, review, &cm); err != nil {
 			return err
 		}
 
@@ -870,7 +868,7 @@ func (g *GiteaLocalUploader) CreateReviews(reviews ...*base.Review) error {
 		pr, ok := g.prCache[issue.ID]
 		if !ok {
 			var err error
-			pr, err = issues_model.GetPullRequestByIssueIDWithNoAttributes(g.ctx, issue.ID)
+			pr, err = issues_model.GetPullRequestByIssueIDWithNoAttributes(ctx, issue.ID)
 			if err != nil {
 				return err
 			}
@@ -940,7 +938,7 @@ func (g *GiteaLocalUploader) CreateReviews(reviews ...*base.Review) error {
 				UpdatedUnix: timeutil.TimeStamp(comment.UpdatedAt.Unix()),
 			}
 
-			if err := g.remapUser(review, &c); err != nil {
+			if err := g.remapUser(ctx, review, &c); err != nil {
 				return err
 			}
 
@@ -948,7 +946,7 @@ func (g *GiteaLocalUploader) CreateReviews(reviews ...*base.Review) error {
 		}
 	}
 
-	return issues_model.InsertReviews(g.ctx, cms)
+	return issues_model.InsertReviews(ctx, cms)
 }
 
 // Rollback when migrating failed, this will rollback all the changes.
@@ -962,31 +960,31 @@ func (g *GiteaLocalUploader) Rollback() error {
 }
 
 // Finish when migrating success, this will do some status update things.
-func (g *GiteaLocalUploader) Finish() error {
+func (g *GiteaLocalUploader) Finish(ctx context.Context) error {
 	if g.repo == nil || g.repo.ID <= 0 {
 		return ErrRepoNotCreated
 	}
 
 	// update issue_index
-	if err := issues_model.RecalculateIssueIndexForRepo(g.ctx, g.repo.ID); err != nil {
+	if err := issues_model.RecalculateIssueIndexForRepo(ctx, g.repo.ID); err != nil {
 		return err
 	}
 
-	if err := models.UpdateRepoStats(g.ctx, g.repo.ID); err != nil {
+	if err := models.UpdateRepoStats(ctx, g.repo.ID); err != nil {
 		return err
 	}
 
 	g.repo.Status = repo_model.RepositoryReady
-	return repo_model.UpdateRepositoryCols(g.ctx, g.repo, "status")
+	return repo_model.UpdateRepositoryCols(ctx, g.repo, "status")
 }
 
-func (g *GiteaLocalUploader) remapUser(source user_model.ExternalUserMigrated, target user_model.ExternalUserRemappable) error {
+func (g *GiteaLocalUploader) remapUser(ctx context.Context, source user_model.ExternalUserMigrated, target user_model.ExternalUserRemappable) error {
 	var userID int64
 	var err error
 	if g.sameApp {
-		userID, err = g.remapLocalUser(source)
+		userID, err = g.remapLocalUser(ctx, source)
 	} else {
-		userID, err = g.remapExternalUser(source)
+		userID, err = g.remapExternalUser(ctx, source)
 	}
 	if err != nil {
 		return err
@@ -998,10 +996,10 @@ func (g *GiteaLocalUploader) remapUser(source user_model.ExternalUserMigrated, t
 	return target.RemapExternalUser(source.GetExternalName(), source.GetExternalID(), g.doer.ID)
 }
 
-func (g *GiteaLocalUploader) remapLocalUser(source user_model.ExternalUserMigrated) (int64, error) {
+func (g *GiteaLocalUploader) remapLocalUser(ctx context.Context, source user_model.ExternalUserMigrated) (int64, error) {
 	userid, ok := g.userMap[source.GetExternalID()]
 	if !ok {
-		name, err := user_model.GetUserNameByID(g.ctx, source.GetExternalID())
+		name, err := user_model.GetUserNameByID(ctx, source.GetExternalID())
 		if err != nil {
 			return 0, err
 		}
@@ -1016,10 +1014,10 @@ func (g *GiteaLocalUploader) remapLocalUser(source user_model.ExternalUserMigrat
 	return userid, nil
 }
 
-func (g *GiteaLocalUploader) remapExternalUser(source user_model.ExternalUserMigrated) (userid int64, err error) {
+func (g *GiteaLocalUploader) remapExternalUser(ctx context.Context, source user_model.ExternalUserMigrated) (userid int64, err error) {
 	userid, ok := g.userMap[source.GetExternalID()]
 	if !ok {
-		userid, err = user_model.GetUserIDByExternalUserID(g.ctx, g.gitServiceType.Name(), fmt.Sprintf("%d", source.GetExternalID()))
+		userid, err = user_model.GetUserIDByExternalUserID(ctx, g.gitServiceType.Name(), fmt.Sprintf("%d", source.GetExternalID()))
 		if err != nil {
 			log.Error("GetUserIDByExternalUserID: %v", err)
 			return 0, err
diff --git a/services/migrations/gitea_uploader_test.go b/services/migrations/gitea_uploader_test.go
index f2379dadf8..18d1171597 100644
--- a/services/migrations/gitea_uploader_test.go
+++ b/services/migrations/gitea_uploader_test.go
@@ -132,8 +132,9 @@ func TestGiteaUploadRemapLocalUser(t *testing.T) {
 	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
 	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
 
+	ctx := context.Background()
 	repoName := "migrated"
-	uploader := NewGiteaLocalUploader(context.Background(), doer, doer.Name, repoName)
+	uploader := NewGiteaLocalUploader(ctx, doer, doer.Name, repoName)
 	// call remapLocalUser
 	uploader.sameApp = true
 
@@ -150,7 +151,7 @@ func TestGiteaUploadRemapLocalUser(t *testing.T) {
 	//
 	target := repo_model.Release{}
 	uploader.userMap = make(map[int64]int64)
-	err := uploader.remapUser(&source, &target)
+	err := uploader.remapUser(ctx, &source, &target)
 	assert.NoError(t, err)
 	assert.EqualValues(t, doer.ID, target.GetUserID())
 
@@ -161,7 +162,7 @@ func TestGiteaUploadRemapLocalUser(t *testing.T) {
 	source.PublisherID = user.ID
 	target = repo_model.Release{}
 	uploader.userMap = make(map[int64]int64)
-	err = uploader.remapUser(&source, &target)
+	err = uploader.remapUser(ctx, &source, &target)
 	assert.NoError(t, err)
 	assert.EqualValues(t, doer.ID, target.GetUserID())
 
@@ -172,7 +173,7 @@ func TestGiteaUploadRemapLocalUser(t *testing.T) {
 	source.PublisherName = user.Name
 	target = repo_model.Release{}
 	uploader.userMap = make(map[int64]int64)
-	err = uploader.remapUser(&source, &target)
+	err = uploader.remapUser(ctx, &source, &target)
 	assert.NoError(t, err)
 	assert.EqualValues(t, user.ID, target.GetUserID())
 }
@@ -180,9 +181,9 @@ func TestGiteaUploadRemapLocalUser(t *testing.T) {
 func TestGiteaUploadRemapExternalUser(t *testing.T) {
 	unittest.PrepareTestEnv(t)
 	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
-
+	ctx := context.Background()
 	repoName := "migrated"
-	uploader := NewGiteaLocalUploader(context.Background(), doer, doer.Name, repoName)
+	uploader := NewGiteaLocalUploader(ctx, doer, doer.Name, repoName)
 	uploader.gitServiceType = structs.GiteaService
 	// call remapExternalUser
 	uploader.sameApp = false
@@ -200,7 +201,7 @@ func TestGiteaUploadRemapExternalUser(t *testing.T) {
 	//
 	uploader.userMap = make(map[int64]int64)
 	target := repo_model.Release{}
-	err := uploader.remapUser(&source, &target)
+	err := uploader.remapUser(ctx, &source, &target)
 	assert.NoError(t, err)
 	assert.EqualValues(t, doer.ID, target.GetUserID())
 
@@ -223,7 +224,7 @@ func TestGiteaUploadRemapExternalUser(t *testing.T) {
 	//
 	uploader.userMap = make(map[int64]int64)
 	target = repo_model.Release{}
-	err = uploader.remapUser(&source, &target)
+	err = uploader.remapUser(ctx, &source, &target)
 	assert.NoError(t, err)
 	assert.EqualValues(t, linkedUser.ID, target.GetUserID())
 }
@@ -301,11 +302,12 @@ func TestGiteaUploadUpdateGitForPullRequest(t *testing.T) {
 	assert.NoError(t, err)
 
 	toRepoName := "migrated"
-	uploader := NewGiteaLocalUploader(context.Background(), fromRepoOwner, fromRepoOwner.Name, toRepoName)
+	ctx := context.Background()
+	uploader := NewGiteaLocalUploader(ctx, fromRepoOwner, fromRepoOwner.Name, toRepoName)
 	uploader.gitServiceType = structs.GiteaService
 
 	assert.NoError(t, repo_service.Init(context.Background()))
-	assert.NoError(t, uploader.CreateRepo(&base.Repository{
+	assert.NoError(t, uploader.CreateRepo(ctx, &base.Repository{
 		Description: "description",
 		OriginalURL: fromRepo.RepoPath(),
 		CloneURL:    fromRepo.RepoPath(),
@@ -505,7 +507,7 @@ func TestGiteaUploadUpdateGitForPullRequest(t *testing.T) {
 
 			testCase.pr.EnsuredSafe = true
 
-			head, err := uploader.updateGitForPullRequest(&testCase.pr)
+			head, err := uploader.updateGitForPullRequest(ctx, &testCase.pr)
 			assert.NoError(t, err)
 			assert.EqualValues(t, testCase.head, head)
 
diff --git a/services/migrations/github.go b/services/migrations/github.go
index 604ab84b39..b00d6ed27f 100644
--- a/services/migrations/github.go
+++ b/services/migrations/github.go
@@ -64,7 +64,6 @@ func (f *GithubDownloaderV3Factory) GitServiceType() structs.GitServiceType {
 // from github via APIv3
 type GithubDownloaderV3 struct {
 	base.NullDownloader
-	ctx           context.Context
 	clients       []*github.Client
 	baseURL       string
 	repoOwner     string
@@ -79,12 +78,11 @@ type GithubDownloaderV3 struct {
 }
 
 // NewGithubDownloaderV3 creates a github Downloader via github v3 API
-func NewGithubDownloaderV3(ctx context.Context, baseURL, userName, password, token, repoOwner, repoName string) *GithubDownloaderV3 {
+func NewGithubDownloaderV3(_ context.Context, baseURL, userName, password, token, repoOwner, repoName string) *GithubDownloaderV3 {
 	downloader := GithubDownloaderV3{
 		userName:   userName,
 		baseURL:    baseURL,
 		password:   password,
-		ctx:        ctx,
 		repoOwner:  repoOwner,
 		repoName:   repoName,
 		maxPerPage: 100,
@@ -141,12 +139,7 @@ func (g *GithubDownloaderV3) addClient(client *http.Client, baseURL string) {
 	g.rates = append(g.rates, nil)
 }
 
-// SetContext set context
-func (g *GithubDownloaderV3) SetContext(ctx context.Context) {
-	g.ctx = ctx
-}
-
-func (g *GithubDownloaderV3) waitAndPickClient() {
+func (g *GithubDownloaderV3) waitAndPickClient(ctx context.Context) {
 	var recentIdx int
 	var maxRemaining int
 	for i := 0; i < len(g.clients); i++ {
@@ -160,13 +153,13 @@ func (g *GithubDownloaderV3) waitAndPickClient() {
 	for g.rates[g.curClientIdx] != nil && g.rates[g.curClientIdx].Remaining <= GithubLimitRateRemaining {
 		timer := time.NewTimer(time.Until(g.rates[g.curClientIdx].Reset.Time))
 		select {
-		case <-g.ctx.Done():
+		case <-ctx.Done():
 			timer.Stop()
 			return
 		case <-timer.C:
 		}
 
-		err := g.RefreshRate()
+		err := g.RefreshRate(ctx)
 		if err != nil {
 			log.Error("g.getClient().RateLimit.Get: %s", err)
 		}
@@ -174,8 +167,8 @@ func (g *GithubDownloaderV3) waitAndPickClient() {
 }
 
 // RefreshRate update the current rate (doesn't count in rate limit)
-func (g *GithubDownloaderV3) RefreshRate() error {
-	rates, _, err := g.getClient().RateLimit.Get(g.ctx)
+func (g *GithubDownloaderV3) RefreshRate(ctx context.Context) error {
+	rates, _, err := g.getClient().RateLimit.Get(ctx)
 	if err != nil {
 		// if rate limit is not enabled, ignore it
 		if strings.Contains(err.Error(), "404") {
@@ -198,9 +191,9 @@ func (g *GithubDownloaderV3) setRate(rate *github.Rate) {
 }
 
 // GetRepoInfo returns a repository information
-func (g *GithubDownloaderV3) GetRepoInfo() (*base.Repository, error) {
-	g.waitAndPickClient()
-	gr, resp, err := g.getClient().Repositories.Get(g.ctx, g.repoOwner, g.repoName)
+func (g *GithubDownloaderV3) GetRepoInfo(ctx context.Context) (*base.Repository, error) {
+	g.waitAndPickClient(ctx)
+	gr, resp, err := g.getClient().Repositories.Get(ctx, g.repoOwner, g.repoName)
 	if err != nil {
 		return nil, err
 	}
@@ -219,9 +212,9 @@ func (g *GithubDownloaderV3) GetRepoInfo() (*base.Repository, error) {
 }
 
 // GetTopics return github topics
-func (g *GithubDownloaderV3) GetTopics() ([]string, error) {
-	g.waitAndPickClient()
-	r, resp, err := g.getClient().Repositories.Get(g.ctx, g.repoOwner, g.repoName)
+func (g *GithubDownloaderV3) GetTopics(ctx context.Context) ([]string, error) {
+	g.waitAndPickClient(ctx)
+	r, resp, err := g.getClient().Repositories.Get(ctx, g.repoOwner, g.repoName)
 	if err != nil {
 		return nil, err
 	}
@@ -230,12 +223,12 @@ func (g *GithubDownloaderV3) GetTopics() ([]string, error) {
 }
 
 // GetMilestones returns milestones
-func (g *GithubDownloaderV3) GetMilestones() ([]*base.Milestone, error) {
+func (g *GithubDownloaderV3) GetMilestones(ctx context.Context) ([]*base.Milestone, error) {
 	perPage := g.maxPerPage
 	milestones := make([]*base.Milestone, 0, perPage)
 	for i := 1; ; i++ {
-		g.waitAndPickClient()
-		ms, resp, err := g.getClient().Issues.ListMilestones(g.ctx, g.repoOwner, g.repoName,
+		g.waitAndPickClient(ctx)
+		ms, resp, err := g.getClient().Issues.ListMilestones(ctx, g.repoOwner, g.repoName,
 			&github.MilestoneListOptions{
 				State: "all",
 				ListOptions: github.ListOptions{
@@ -279,12 +272,12 @@ func convertGithubLabel(label *github.Label) *base.Label {
 }
 
 // GetLabels returns labels
-func (g *GithubDownloaderV3) GetLabels() ([]*base.Label, error) {
+func (g *GithubDownloaderV3) GetLabels(ctx context.Context) ([]*base.Label, error) {
 	perPage := g.maxPerPage
 	labels := make([]*base.Label, 0, perPage)
 	for i := 1; ; i++ {
-		g.waitAndPickClient()
-		ls, resp, err := g.getClient().Issues.ListLabels(g.ctx, g.repoOwner, g.repoName,
+		g.waitAndPickClient(ctx)
+		ls, resp, err := g.getClient().Issues.ListLabels(ctx, g.repoOwner, g.repoName,
 			&github.ListOptions{
 				Page:    i,
 				PerPage: perPage,
@@ -304,7 +297,7 @@ func (g *GithubDownloaderV3) GetLabels() ([]*base.Label, error) {
 	return labels, nil
 }
 
-func (g *GithubDownloaderV3) convertGithubRelease(rel *github.RepositoryRelease) *base.Release {
+func (g *GithubDownloaderV3) convertGithubRelease(ctx context.Context, rel *github.RepositoryRelease) *base.Release {
 	// GitHub allows commitish to be a reference.
 	// In this case, we need to remove the prefix, i.e. convert "refs/heads/main" to "main".
 	targetCommitish := strings.TrimPrefix(rel.GetTargetCommitish(), git.BranchPrefix)
@@ -339,12 +332,12 @@ func (g *GithubDownloaderV3) convertGithubRelease(rel *github.RepositoryRelease)
 			Created:       asset.CreatedAt.Time,
 			Updated:       asset.UpdatedAt.Time,
 			DownloadFunc: func() (io.ReadCloser, error) {
-				g.waitAndPickClient()
-				readCloser, redirectURL, err := g.getClient().Repositories.DownloadReleaseAsset(g.ctx, g.repoOwner, g.repoName, assetID, nil)
+				g.waitAndPickClient(ctx)
+				readCloser, redirectURL, err := g.getClient().Repositories.DownloadReleaseAsset(ctx, g.repoOwner, g.repoName, assetID, nil)
 				if err != nil {
 					return nil, err
 				}
-				if err := g.RefreshRate(); err != nil {
+				if err := g.RefreshRate(ctx); err != nil {
 					log.Error("g.getClient().RateLimits: %s", err)
 				}
 
@@ -364,13 +357,13 @@ func (g *GithubDownloaderV3) convertGithubRelease(rel *github.RepositoryRelease)
 					return io.NopCloser(strings.NewReader(redirectURL)), nil
 				}
 
-				g.waitAndPickClient()
-				req, err := http.NewRequestWithContext(g.ctx, "GET", redirectURL, nil)
+				g.waitAndPickClient(ctx)
+				req, err := http.NewRequestWithContext(ctx, "GET", redirectURL, nil)
 				if err != nil {
 					return nil, err
 				}
 				resp, err := httpClient.Do(req)
-				err1 := g.RefreshRate()
+				err1 := g.RefreshRate(ctx)
 				if err1 != nil {
 					log.Error("g.RefreshRate(): %s", err1)
 				}
@@ -385,12 +378,12 @@ func (g *GithubDownloaderV3) convertGithubRelease(rel *github.RepositoryRelease)
 }
 
 // GetReleases returns releases
-func (g *GithubDownloaderV3) GetReleases() ([]*base.Release, error) {
+func (g *GithubDownloaderV3) GetReleases(ctx context.Context) ([]*base.Release, error) {
 	perPage := g.maxPerPage
 	releases := make([]*base.Release, 0, perPage)
 	for i := 1; ; i++ {
-		g.waitAndPickClient()
-		ls, resp, err := g.getClient().Repositories.ListReleases(g.ctx, g.repoOwner, g.repoName,
+		g.waitAndPickClient(ctx)
+		ls, resp, err := g.getClient().Repositories.ListReleases(ctx, g.repoOwner, g.repoName,
 			&github.ListOptions{
 				Page:    i,
 				PerPage: perPage,
@@ -401,7 +394,7 @@ func (g *GithubDownloaderV3) GetReleases() ([]*base.Release, error) {
 		g.setRate(&resp.Rate)
 
 		for _, release := range ls {
-			releases = append(releases, g.convertGithubRelease(release))
+			releases = append(releases, g.convertGithubRelease(ctx, release))
 		}
 		if len(ls) < perPage {
 			break
@@ -411,7 +404,7 @@ func (g *GithubDownloaderV3) GetReleases() ([]*base.Release, error) {
 }
 
 // GetIssues returns issues according start and limit
-func (g *GithubDownloaderV3) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
+func (g *GithubDownloaderV3) GetIssues(ctx context.Context, page, perPage int) ([]*base.Issue, bool, error) {
 	if perPage > g.maxPerPage {
 		perPage = g.maxPerPage
 	}
@@ -426,8 +419,8 @@ func (g *GithubDownloaderV3) GetIssues(page, perPage int) ([]*base.Issue, bool,
 	}
 
 	allIssues := make([]*base.Issue, 0, perPage)
-	g.waitAndPickClient()
-	issues, resp, err := g.getClient().Issues.ListByRepo(g.ctx, g.repoOwner, g.repoName, opt)
+	g.waitAndPickClient(ctx)
+	issues, resp, err := g.getClient().Issues.ListByRepo(ctx, g.repoOwner, g.repoName, opt)
 	if err != nil {
 		return nil, false, fmt.Errorf("error while listing repos: %w", err)
 	}
@@ -447,8 +440,8 @@ func (g *GithubDownloaderV3) GetIssues(page, perPage int) ([]*base.Issue, bool,
 		var reactions []*base.Reaction
 		if !g.SkipReactions {
 			for i := 1; ; i++ {
-				g.waitAndPickClient()
-				res, resp, err := g.getClient().Reactions.ListIssueReactions(g.ctx, g.repoOwner, g.repoName, issue.GetNumber(), &github.ListOptions{
+				g.waitAndPickClient(ctx)
+				res, resp, err := g.getClient().Reactions.ListIssueReactions(ctx, g.repoOwner, g.repoName, issue.GetNumber(), &github.ListOptions{
 					Page:    i,
 					PerPage: perPage,
 				})
@@ -503,12 +496,12 @@ func (g *GithubDownloaderV3) SupportGetRepoComments() bool {
 }
 
 // GetComments returns comments according issueNumber
-func (g *GithubDownloaderV3) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
-	comments, err := g.getComments(commentable)
+func (g *GithubDownloaderV3) GetComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
+	comments, err := g.getComments(ctx, commentable)
 	return comments, false, err
 }
 
-func (g *GithubDownloaderV3) getComments(commentable base.Commentable) ([]*base.Comment, error) {
+func (g *GithubDownloaderV3) getComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, error) {
 	var (
 		allComments = make([]*base.Comment, 0, g.maxPerPage)
 		created     = "created"
@@ -522,8 +515,8 @@ func (g *GithubDownloaderV3) getComments(commentable base.Commentable) ([]*base.
 		},
 	}
 	for {
-		g.waitAndPickClient()
-		comments, resp, err := g.getClient().Issues.ListComments(g.ctx, g.repoOwner, g.repoName, int(commentable.GetForeignIndex()), opt)
+		g.waitAndPickClient(ctx)
+		comments, resp, err := g.getClient().Issues.ListComments(ctx, g.repoOwner, g.repoName, int(commentable.GetForeignIndex()), opt)
 		if err != nil {
 			return nil, fmt.Errorf("error while listing repos: %w", err)
 		}
@@ -533,8 +526,8 @@ func (g *GithubDownloaderV3) getComments(commentable base.Commentable) ([]*base.
 			var reactions []*base.Reaction
 			if !g.SkipReactions {
 				for i := 1; ; i++ {
-					g.waitAndPickClient()
-					res, resp, err := g.getClient().Reactions.ListIssueCommentReactions(g.ctx, g.repoOwner, g.repoName, comment.GetID(), &github.ListOptions{
+					g.waitAndPickClient(ctx)
+					res, resp, err := g.getClient().Reactions.ListIssueCommentReactions(ctx, g.repoOwner, g.repoName, comment.GetID(), &github.ListOptions{
 						Page:    i,
 						PerPage: g.maxPerPage,
 					})
@@ -576,7 +569,7 @@ func (g *GithubDownloaderV3) getComments(commentable base.Commentable) ([]*base.
 }
 
 // GetAllComments returns repository comments according page and perPageSize
-func (g *GithubDownloaderV3) GetAllComments(page, perPage int) ([]*base.Comment, bool, error) {
+func (g *GithubDownloaderV3) GetAllComments(ctx context.Context, page, perPage int) ([]*base.Comment, bool, error) {
 	var (
 		allComments = make([]*base.Comment, 0, perPage)
 		created     = "created"
@@ -594,8 +587,8 @@ func (g *GithubDownloaderV3) GetAllComments(page, perPage int) ([]*base.Comment,
 		},
 	}
 
-	g.waitAndPickClient()
-	comments, resp, err := g.getClient().Issues.ListComments(g.ctx, g.repoOwner, g.repoName, 0, opt)
+	g.waitAndPickClient(ctx)
+	comments, resp, err := g.getClient().Issues.ListComments(ctx, g.repoOwner, g.repoName, 0, opt)
 	if err != nil {
 		return nil, false, fmt.Errorf("error while listing repos: %w", err)
 	}
@@ -608,8 +601,8 @@ func (g *GithubDownloaderV3) GetAllComments(page, perPage int) ([]*base.Comment,
 		var reactions []*base.Reaction
 		if !g.SkipReactions {
 			for i := 1; ; i++ {
-				g.waitAndPickClient()
-				res, resp, err := g.getClient().Reactions.ListIssueCommentReactions(g.ctx, g.repoOwner, g.repoName, comment.GetID(), &github.ListOptions{
+				g.waitAndPickClient(ctx)
+				res, resp, err := g.getClient().Reactions.ListIssueCommentReactions(ctx, g.repoOwner, g.repoName, comment.GetID(), &github.ListOptions{
 					Page:    i,
 					PerPage: g.maxPerPage,
 				})
@@ -648,7 +641,7 @@ func (g *GithubDownloaderV3) GetAllComments(page, perPage int) ([]*base.Comment,
 }
 
 // GetPullRequests returns pull requests according page and perPage
-func (g *GithubDownloaderV3) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
+func (g *GithubDownloaderV3) GetPullRequests(ctx context.Context, page, perPage int) ([]*base.PullRequest, bool, error) {
 	if perPage > g.maxPerPage {
 		perPage = g.maxPerPage
 	}
@@ -662,8 +655,8 @@ func (g *GithubDownloaderV3) GetPullRequests(page, perPage int) ([]*base.PullReq
 		},
 	}
 	allPRs := make([]*base.PullRequest, 0, perPage)
-	g.waitAndPickClient()
-	prs, resp, err := g.getClient().PullRequests.List(g.ctx, g.repoOwner, g.repoName, opt)
+	g.waitAndPickClient(ctx)
+	prs, resp, err := g.getClient().PullRequests.List(ctx, g.repoOwner, g.repoName, opt)
 	if err != nil {
 		return nil, false, fmt.Errorf("error while listing repos: %w", err)
 	}
@@ -679,8 +672,8 @@ func (g *GithubDownloaderV3) GetPullRequests(page, perPage int) ([]*base.PullReq
 		var reactions []*base.Reaction
 		if !g.SkipReactions {
 			for i := 1; ; i++ {
-				g.waitAndPickClient()
-				res, resp, err := g.getClient().Reactions.ListIssueReactions(g.ctx, g.repoOwner, g.repoName, pr.GetNumber(), &github.ListOptions{
+				g.waitAndPickClient(ctx)
+				res, resp, err := g.getClient().Reactions.ListIssueReactions(ctx, g.repoOwner, g.repoName, pr.GetNumber(), &github.ListOptions{
 					Page:    i,
 					PerPage: perPage,
 				})
@@ -702,7 +695,7 @@ func (g *GithubDownloaderV3) GetPullRequests(page, perPage int) ([]*base.PullReq
 		}
 
 		// download patch and saved as tmp file
-		g.waitAndPickClient()
+		g.waitAndPickClient(ctx)
 
 		allPRs = append(allPRs, &base.PullRequest{
 			Title:          pr.GetTitle(),
@@ -759,15 +752,15 @@ func convertGithubReview(r *github.PullRequestReview) *base.Review {
 	}
 }
 
-func (g *GithubDownloaderV3) convertGithubReviewComments(cs []*github.PullRequestComment) ([]*base.ReviewComment, error) {
+func (g *GithubDownloaderV3) convertGithubReviewComments(ctx context.Context, cs []*github.PullRequestComment) ([]*base.ReviewComment, error) {
 	rcs := make([]*base.ReviewComment, 0, len(cs))
 	for _, c := range cs {
 		// get reactions
 		var reactions []*base.Reaction
 		if !g.SkipReactions {
 			for i := 1; ; i++ {
-				g.waitAndPickClient()
-				res, resp, err := g.getClient().Reactions.ListPullRequestCommentReactions(g.ctx, g.repoOwner, g.repoName, c.GetID(), &github.ListOptions{
+				g.waitAndPickClient(ctx)
+				res, resp, err := g.getClient().Reactions.ListPullRequestCommentReactions(ctx, g.repoOwner, g.repoName, c.GetID(), &github.ListOptions{
 					Page:    i,
 					PerPage: g.maxPerPage,
 				})
@@ -806,7 +799,7 @@ func (g *GithubDownloaderV3) convertGithubReviewComments(cs []*github.PullReques
 }
 
 // GetReviews returns pull requests review
-func (g *GithubDownloaderV3) GetReviews(reviewable base.Reviewable) ([]*base.Review, error) {
+func (g *GithubDownloaderV3) GetReviews(ctx context.Context, reviewable base.Reviewable) ([]*base.Review, error) {
 	allReviews := make([]*base.Review, 0, g.maxPerPage)
 	if g.SkipReviews {
 		return allReviews, nil
@@ -816,8 +809,8 @@ func (g *GithubDownloaderV3) GetReviews(reviewable base.Reviewable) ([]*base.Rev
 	}
 	// Get approve/request change reviews
 	for {
-		g.waitAndPickClient()
-		reviews, resp, err := g.getClient().PullRequests.ListReviews(g.ctx, g.repoOwner, g.repoName, int(reviewable.GetForeignIndex()), opt)
+		g.waitAndPickClient(ctx)
+		reviews, resp, err := g.getClient().PullRequests.ListReviews(ctx, g.repoOwner, g.repoName, int(reviewable.GetForeignIndex()), opt)
 		if err != nil {
 			return nil, fmt.Errorf("error while listing repos: %w", err)
 		}
@@ -830,14 +823,14 @@ func (g *GithubDownloaderV3) GetReviews(reviewable base.Reviewable) ([]*base.Rev
 				PerPage: g.maxPerPage,
 			}
 			for {
-				g.waitAndPickClient()
-				reviewComments, resp, err := g.getClient().PullRequests.ListReviewComments(g.ctx, g.repoOwner, g.repoName, int(reviewable.GetForeignIndex()), review.GetID(), opt2)
+				g.waitAndPickClient(ctx)
+				reviewComments, resp, err := g.getClient().PullRequests.ListReviewComments(ctx, g.repoOwner, g.repoName, int(reviewable.GetForeignIndex()), review.GetID(), opt2)
 				if err != nil {
 					return nil, fmt.Errorf("error while listing repos: %w", err)
 				}
 				g.setRate(&resp.Rate)
 
-				cs, err := g.convertGithubReviewComments(reviewComments)
+				cs, err := g.convertGithubReviewComments(ctx, reviewComments)
 				if err != nil {
 					return nil, err
 				}
@@ -856,8 +849,8 @@ func (g *GithubDownloaderV3) GetReviews(reviewable base.Reviewable) ([]*base.Rev
 	}
 	// Get requested reviews
 	for {
-		g.waitAndPickClient()
-		reviewers, resp, err := g.getClient().PullRequests.ListReviewers(g.ctx, g.repoOwner, g.repoName, int(reviewable.GetForeignIndex()), opt)
+		g.waitAndPickClient(ctx)
+		reviewers, resp, err := g.getClient().PullRequests.ListReviewers(ctx, g.repoOwner, g.repoName, int(reviewable.GetForeignIndex()), opt)
 		if err != nil {
 			return nil, fmt.Errorf("error while listing repos: %w", err)
 		}
diff --git a/services/migrations/github_test.go b/services/migrations/github_test.go
index 2b89e6dc0f..899f9fe52c 100644
--- a/services/migrations/github_test.go
+++ b/services/migrations/github_test.go
@@ -21,11 +21,12 @@ func TestGitHubDownloadRepo(t *testing.T) {
 	if token == "" {
 		t.Skip("Skipping GitHub migration test because GITHUB_READ_TOKEN is empty")
 	}
-	downloader := NewGithubDownloaderV3(context.Background(), "https://github.com", "", "", token, "go-gitea", "test_repo")
-	err := downloader.RefreshRate()
+	ctx := context.Background()
+	downloader := NewGithubDownloaderV3(ctx, "https://github.com", "", "", token, "go-gitea", "test_repo")
+	err := downloader.RefreshRate(ctx)
 	assert.NoError(t, err)
 
-	repo, err := downloader.GetRepoInfo()
+	repo, err := downloader.GetRepoInfo(ctx)
 	assert.NoError(t, err)
 	assertRepositoryEqual(t, &base.Repository{
 		Name:          "test_repo",
@@ -36,11 +37,11 @@ func TestGitHubDownloadRepo(t *testing.T) {
 		DefaultBranch: "master",
 	}, repo)
 
-	topics, err := downloader.GetTopics()
+	topics, err := downloader.GetTopics(ctx)
 	assert.NoError(t, err)
 	assert.Contains(t, topics, "gitea")
 
-	milestones, err := downloader.GetMilestones()
+	milestones, err := downloader.GetMilestones(ctx)
 	assert.NoError(t, err)
 	assertMilestonesEqual(t, []*base.Milestone{
 		{
@@ -63,7 +64,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
 		},
 	}, milestones)
 
-	labels, err := downloader.GetLabels()
+	labels, err := downloader.GetLabels(ctx)
 	assert.NoError(t, err)
 	assertLabelsEqual(t, []*base.Label{
 		{
@@ -113,7 +114,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
 		},
 	}, labels)
 
-	releases, err := downloader.GetReleases()
+	releases, err := downloader.GetReleases(ctx)
 	assert.NoError(t, err)
 	assertReleasesEqual(t, []*base.Release{
 		{
@@ -129,7 +130,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
 	}, releases)
 
 	// downloader.GetIssues()
-	issues, isEnd, err := downloader.GetIssues(1, 2)
+	issues, isEnd, err := downloader.GetIssues(ctx, 1, 2)
 	assert.NoError(t, err)
 	assert.False(t, isEnd)
 	assertIssuesEqual(t, []*base.Issue{
@@ -218,7 +219,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
 	}, issues)
 
 	// downloader.GetComments()
-	comments, _, err := downloader.GetComments(&base.Issue{Number: 2, ForeignIndex: 2})
+	comments, _, err := downloader.GetComments(ctx, &base.Issue{Number: 2, ForeignIndex: 2})
 	assert.NoError(t, err)
 	assertCommentsEqual(t, []*base.Comment{
 		{
@@ -248,7 +249,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
 	}, comments)
 
 	// downloader.GetPullRequests()
-	prs, _, err := downloader.GetPullRequests(1, 2)
+	prs, _, err := downloader.GetPullRequests(ctx, 1, 2)
 	assert.NoError(t, err)
 	assertPullRequestsEqual(t, []*base.PullRequest{
 		{
@@ -338,7 +339,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
 		},
 	}, prs)
 
-	reviews, err := downloader.GetReviews(&base.PullRequest{Number: 3, ForeignIndex: 3})
+	reviews, err := downloader.GetReviews(ctx, &base.PullRequest{Number: 3, ForeignIndex: 3})
 	assert.NoError(t, err)
 	assertReviewsEqual(t, []*base.Review{
 		{
@@ -370,7 +371,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
 		},
 	}, reviews)
 
-	reviews, err = downloader.GetReviews(&base.PullRequest{Number: 4, ForeignIndex: 4})
+	reviews, err = downloader.GetReviews(ctx, &base.PullRequest{Number: 4, ForeignIndex: 4})
 	assert.NoError(t, err)
 	assertReviewsEqual(t, []*base.Review{
 		{
diff --git a/services/migrations/gitlab.go b/services/migrations/gitlab.go
index 07d5040b5b..efc5b960cf 100644
--- a/services/migrations/gitlab.go
+++ b/services/migrations/gitlab.go
@@ -80,7 +80,6 @@ func (r *gitlabIIDResolver) generatePullRequestNumber(mrIID int) int64 {
 // because Gitlab has individual Issue and Pull Request numbers.
 type GitlabDownloader struct {
 	base.NullDownloader
-	ctx         context.Context
 	client      *gitlab.Client
 	baseURL     string
 	repoID      int
@@ -143,7 +142,6 @@ func NewGitlabDownloader(ctx context.Context, baseURL, repoPath, username, passw
 	}
 
 	return &GitlabDownloader{
-		ctx:        ctx,
 		client:     gitlabClient,
 		baseURL:    baseURL,
 		repoID:     gr.ID,
@@ -164,14 +162,9 @@ func (g *GitlabDownloader) LogString() string {
 	return fmt.Sprintf("<GitlabDownloader %s [%d]/%s>", g.baseURL, g.repoID, g.repoName)
 }
 
-// SetContext set context
-func (g *GitlabDownloader) SetContext(ctx context.Context) {
-	g.ctx = ctx
-}
-
 // GetRepoInfo returns a repository information
-func (g *GitlabDownloader) GetRepoInfo() (*base.Repository, error) {
-	gr, _, err := g.client.Projects.GetProject(g.repoID, nil, nil, gitlab.WithContext(g.ctx))
+func (g *GitlabDownloader) GetRepoInfo(ctx context.Context) (*base.Repository, error) {
+	gr, _, err := g.client.Projects.GetProject(g.repoID, nil, nil, gitlab.WithContext(ctx))
 	if err != nil {
 		return nil, err
 	}
@@ -207,8 +200,8 @@ func (g *GitlabDownloader) GetRepoInfo() (*base.Repository, error) {
 }
 
 // GetTopics return gitlab topics
-func (g *GitlabDownloader) GetTopics() ([]string, error) {
-	gr, _, err := g.client.Projects.GetProject(g.repoID, nil, nil, gitlab.WithContext(g.ctx))
+func (g *GitlabDownloader) GetTopics(ctx context.Context) ([]string, error) {
+	gr, _, err := g.client.Projects.GetProject(g.repoID, nil, nil, gitlab.WithContext(ctx))
 	if err != nil {
 		return nil, err
 	}
@@ -216,7 +209,7 @@ func (g *GitlabDownloader) GetTopics() ([]string, error) {
 }
 
 // GetMilestones returns milestones
-func (g *GitlabDownloader) GetMilestones() ([]*base.Milestone, error) {
+func (g *GitlabDownloader) GetMilestones(ctx context.Context) ([]*base.Milestone, error) {
 	perPage := g.maxPerPage
 	state := "all"
 	milestones := make([]*base.Milestone, 0, perPage)
@@ -227,7 +220,7 @@ func (g *GitlabDownloader) GetMilestones() ([]*base.Milestone, error) {
 				Page:    i,
 				PerPage: perPage,
 			},
-		}, nil, gitlab.WithContext(g.ctx))
+		}, nil, gitlab.WithContext(ctx))
 		if err != nil {
 			return nil, err
 		}
@@ -288,14 +281,14 @@ func (g *GitlabDownloader) normalizeColor(val string) string {
 }
 
 // GetLabels returns labels
-func (g *GitlabDownloader) GetLabels() ([]*base.Label, error) {
+func (g *GitlabDownloader) GetLabels(ctx context.Context) ([]*base.Label, error) {
 	perPage := g.maxPerPage
 	labels := make([]*base.Label, 0, perPage)
 	for i := 1; ; i++ {
 		ls, _, err := g.client.Labels.ListLabels(g.repoID, &gitlab.ListLabelsOptions{ListOptions: gitlab.ListOptions{
 			Page:    i,
 			PerPage: perPage,
-		}}, nil, gitlab.WithContext(g.ctx))
+		}}, nil, gitlab.WithContext(ctx))
 		if err != nil {
 			return nil, err
 		}
@@ -314,7 +307,7 @@ func (g *GitlabDownloader) GetLabels() ([]*base.Label, error) {
 	return labels, nil
 }
 
-func (g *GitlabDownloader) convertGitlabRelease(rel *gitlab.Release) *base.Release {
+func (g *GitlabDownloader) convertGitlabRelease(ctx context.Context, rel *gitlab.Release) *base.Release {
 	var zero int
 	r := &base.Release{
 		TagName:         rel.TagName,
@@ -337,7 +330,7 @@ func (g *GitlabDownloader) convertGitlabRelease(rel *gitlab.Release) *base.Relea
 			Size:          &zero,
 			DownloadCount: &zero,
 			DownloadFunc: func() (io.ReadCloser, error) {
-				link, _, err := g.client.ReleaseLinks.GetReleaseLink(g.repoID, rel.TagName, assetID, gitlab.WithContext(g.ctx))
+				link, _, err := g.client.ReleaseLinks.GetReleaseLink(g.repoID, rel.TagName, assetID, gitlab.WithContext(ctx))
 				if err != nil {
 					return nil, err
 				}
@@ -351,7 +344,7 @@ func (g *GitlabDownloader) convertGitlabRelease(rel *gitlab.Release) *base.Relea
 				if err != nil {
 					return nil, err
 				}
-				req = req.WithContext(g.ctx)
+				req = req.WithContext(ctx)
 				resp, err := httpClient.Do(req)
 				if err != nil {
 					return nil, err
@@ -366,7 +359,7 @@ func (g *GitlabDownloader) convertGitlabRelease(rel *gitlab.Release) *base.Relea
 }
 
 // GetReleases returns releases
-func (g *GitlabDownloader) GetReleases() ([]*base.Release, error) {
+func (g *GitlabDownloader) GetReleases(ctx context.Context) ([]*base.Release, error) {
 	perPage := g.maxPerPage
 	releases := make([]*base.Release, 0, perPage)
 	for i := 1; ; i++ {
@@ -375,13 +368,13 @@ func (g *GitlabDownloader) GetReleases() ([]*base.Release, error) {
 				Page:    i,
 				PerPage: perPage,
 			},
-		}, nil, gitlab.WithContext(g.ctx))
+		}, nil, gitlab.WithContext(ctx))
 		if err != nil {
 			return nil, err
 		}
 
 		for _, release := range ls {
-			releases = append(releases, g.convertGitlabRelease(release))
+			releases = append(releases, g.convertGitlabRelease(ctx, release))
 		}
 		if len(ls) < perPage {
 			break
@@ -397,7 +390,7 @@ type gitlabIssueContext struct {
 // GetIssues returns issues according start and limit
 //
 //	Note: issue label description and colors are not supported by the go-gitlab library at this time
-func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
+func (g *GitlabDownloader) GetIssues(ctx context.Context, page, perPage int) ([]*base.Issue, bool, error) {
 	state := "all"
 	sort := "asc"
 
@@ -416,7 +409,7 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er
 
 	allIssues := make([]*base.Issue, 0, perPage)
 
-	issues, _, err := g.client.Issues.ListProjectIssues(g.repoID, opt, nil, gitlab.WithContext(g.ctx))
+	issues, _, err := g.client.Issues.ListProjectIssues(g.repoID, opt, nil, gitlab.WithContext(ctx))
 	if err != nil {
 		return nil, false, fmt.Errorf("error while listing issues: %w", err)
 	}
@@ -436,7 +429,7 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er
 		var reactions []*gitlab.AwardEmoji
 		awardPage := 1
 		for {
-			awards, _, err := g.client.AwardEmoji.ListIssueAwardEmoji(g.repoID, issue.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx))
+			awards, _, err := g.client.AwardEmoji.ListIssueAwardEmoji(g.repoID, issue.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(ctx))
 			if err != nil {
 				return nil, false, fmt.Errorf("error while listing issue awards: %w", err)
 			}
@@ -477,7 +470,7 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er
 
 // GetComments returns comments according issueNumber
 // TODO: figure out how to transfer comment reactions
-func (g *GitlabDownloader) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
+func (g *GitlabDownloader) GetComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
 	context, ok := commentable.GetContext().(gitlabIssueContext)
 	if !ok {
 		return nil, false, fmt.Errorf("unexpected context: %+v", commentable.GetContext())
@@ -495,12 +488,12 @@ func (g *GitlabDownloader) GetComments(commentable base.Commentable) ([]*base.Co
 			comments, resp, err = g.client.Discussions.ListIssueDiscussions(g.repoID, int(commentable.GetForeignIndex()), &gitlab.ListIssueDiscussionsOptions{
 				Page:    page,
 				PerPage: g.maxPerPage,
-			}, nil, gitlab.WithContext(g.ctx))
+			}, nil, gitlab.WithContext(ctx))
 		} else {
 			comments, resp, err = g.client.Discussions.ListMergeRequestDiscussions(g.repoID, int(commentable.GetForeignIndex()), &gitlab.ListMergeRequestDiscussionsOptions{
 				Page:    page,
 				PerPage: g.maxPerPage,
-			}, nil, gitlab.WithContext(g.ctx))
+			}, nil, gitlab.WithContext(ctx))
 		}
 
 		if err != nil {
@@ -528,14 +521,14 @@ func (g *GitlabDownloader) GetComments(commentable base.Commentable) ([]*base.Co
 					Page:    page,
 					PerPage: g.maxPerPage,
 				},
-			}, nil, gitlab.WithContext(g.ctx))
+			}, nil, gitlab.WithContext(ctx))
 		} else {
 			stateEvents, resp, err = g.client.ResourceStateEvents.ListIssueStateEvents(g.repoID, int(commentable.GetForeignIndex()), &gitlab.ListStateEventsOptions{
 				ListOptions: gitlab.ListOptions{
 					Page:    page,
 					PerPage: g.maxPerPage,
 				},
-			}, nil, gitlab.WithContext(g.ctx))
+			}, nil, gitlab.WithContext(ctx))
 		}
 		if err != nil {
 			return nil, false, fmt.Errorf("error while listing state events: %v %w", g.repoID, err)
@@ -604,7 +597,7 @@ func (g *GitlabDownloader) convertNoteToComment(localIndex int64, note *gitlab.N
 }
 
 // GetPullRequests returns pull requests according page and perPage
-func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
+func (g *GitlabDownloader) GetPullRequests(ctx context.Context, page, perPage int) ([]*base.PullRequest, bool, error) {
 	if perPage > g.maxPerPage {
 		perPage = g.maxPerPage
 	}
@@ -620,7 +613,7 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
 
 	allPRs := make([]*base.PullRequest, 0, perPage)
 
-	prs, _, err := g.client.MergeRequests.ListProjectMergeRequests(g.repoID, opt, nil, gitlab.WithContext(g.ctx))
+	prs, _, err := g.client.MergeRequests.ListProjectMergeRequests(g.repoID, opt, nil, gitlab.WithContext(ctx))
 	if err != nil {
 		return nil, false, fmt.Errorf("error while listing merge requests: %w", err)
 	}
@@ -673,7 +666,7 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
 		var reactions []*gitlab.AwardEmoji
 		awardPage := 1
 		for {
-			awards, _, err := g.client.AwardEmoji.ListMergeRequestAwardEmoji(g.repoID, pr.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx))
+			awards, _, err := g.client.AwardEmoji.ListMergeRequestAwardEmoji(g.repoID, pr.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(ctx))
 			if err != nil {
 				return nil, false, fmt.Errorf("error while listing merge requests awards: %w", err)
 			}
@@ -733,8 +726,8 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
 }
 
 // GetReviews returns pull requests review
-func (g *GitlabDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Review, error) {
-	approvals, resp, err := g.client.MergeRequestApprovals.GetConfiguration(g.repoID, int(reviewable.GetForeignIndex()), gitlab.WithContext(g.ctx))
+func (g *GitlabDownloader) GetReviews(ctx context.Context, reviewable base.Reviewable) ([]*base.Review, error) {
+	approvals, resp, err := g.client.MergeRequestApprovals.GetConfiguration(g.repoID, int(reviewable.GetForeignIndex()), gitlab.WithContext(ctx))
 	if err != nil {
 		if resp != nil && resp.StatusCode == http.StatusNotFound {
 			log.Error(fmt.Sprintf("GitlabDownloader: while migrating a error occurred: '%s'", err.Error()))
diff --git a/services/migrations/gitlab_test.go b/services/migrations/gitlab_test.go
index 556fe771c5..223a3b86d7 100644
--- a/services/migrations/gitlab_test.go
+++ b/services/migrations/gitlab_test.go
@@ -31,12 +31,12 @@ func TestGitlabDownloadRepo(t *testing.T) {
 	if err != nil || resp.StatusCode != http.StatusOK {
 		t.Skipf("Can't access test repo, skipping %s", t.Name())
 	}
-
-	downloader, err := NewGitlabDownloader(context.Background(), "https://gitlab.com", "gitea/test_repo", "", "", gitlabPersonalAccessToken)
+	ctx := context.Background()
+	downloader, err := NewGitlabDownloader(ctx, "https://gitlab.com", "gitea/test_repo", "", "", gitlabPersonalAccessToken)
 	if err != nil {
 		t.Fatalf("NewGitlabDownloader is nil: %v", err)
 	}
-	repo, err := downloader.GetRepoInfo()
+	repo, err := downloader.GetRepoInfo(ctx)
 	assert.NoError(t, err)
 	// Repo Owner is blank in Gitlab Group repos
 	assertRepositoryEqual(t, &base.Repository{
@@ -48,12 +48,12 @@ func TestGitlabDownloadRepo(t *testing.T) {
 		DefaultBranch: "master",
 	}, repo)
 
-	topics, err := downloader.GetTopics()
+	topics, err := downloader.GetTopics(ctx)
 	assert.NoError(t, err)
 	assert.Len(t, topics, 2)
 	assert.EqualValues(t, []string{"migration", "test"}, topics)
 
-	milestones, err := downloader.GetMilestones()
+	milestones, err := downloader.GetMilestones(ctx)
 	assert.NoError(t, err)
 	assertMilestonesEqual(t, []*base.Milestone{
 		{
@@ -71,7 +71,7 @@ func TestGitlabDownloadRepo(t *testing.T) {
 		},
 	}, milestones)
 
-	labels, err := downloader.GetLabels()
+	labels, err := downloader.GetLabels(ctx)
 	assert.NoError(t, err)
 	assertLabelsEqual(t, []*base.Label{
 		{
@@ -112,7 +112,7 @@ func TestGitlabDownloadRepo(t *testing.T) {
 		},
 	}, labels)
 
-	releases, err := downloader.GetReleases()
+	releases, err := downloader.GetReleases(ctx)
 	assert.NoError(t, err)
 	assertReleasesEqual(t, []*base.Release{
 		{
@@ -126,7 +126,7 @@ func TestGitlabDownloadRepo(t *testing.T) {
 		},
 	}, releases)
 
-	issues, isEnd, err := downloader.GetIssues(1, 2)
+	issues, isEnd, err := downloader.GetIssues(ctx, 1, 2)
 	assert.NoError(t, err)
 	assert.False(t, isEnd)
 
@@ -214,7 +214,7 @@ func TestGitlabDownloadRepo(t *testing.T) {
 		},
 	}, issues)
 
-	comments, _, err := downloader.GetComments(&base.Issue{
+	comments, _, err := downloader.GetComments(ctx, &base.Issue{
 		Number:       2,
 		ForeignIndex: 2,
 		Context:      gitlabIssueContext{IsMergeRequest: false},
@@ -255,7 +255,7 @@ func TestGitlabDownloadRepo(t *testing.T) {
 		},
 	}, comments)
 
-	prs, _, err := downloader.GetPullRequests(1, 1)
+	prs, _, err := downloader.GetPullRequests(ctx, 1, 1)
 	assert.NoError(t, err)
 	assertPullRequestsEqual(t, []*base.PullRequest{
 		{
@@ -304,7 +304,7 @@ func TestGitlabDownloadRepo(t *testing.T) {
 		},
 	}, prs)
 
-	rvs, err := downloader.GetReviews(&base.PullRequest{Number: 1, ForeignIndex: 1})
+	rvs, err := downloader.GetReviews(ctx, &base.PullRequest{Number: 1, ForeignIndex: 1})
 	assert.NoError(t, err)
 	assertReviewsEqual(t, []*base.Review{
 		{
@@ -323,7 +323,7 @@ func TestGitlabDownloadRepo(t *testing.T) {
 		},
 	}, rvs)
 
-	rvs, err = downloader.GetReviews(&base.PullRequest{Number: 2, ForeignIndex: 2})
+	rvs, err = downloader.GetReviews(ctx, &base.PullRequest{Number: 2, ForeignIndex: 2})
 	assert.NoError(t, err)
 	assertReviewsEqual(t, []*base.Review{
 		{
@@ -423,9 +423,8 @@ func TestGitlabGetReviews(t *testing.T) {
 	defer gitlabClientMockTeardown(server)
 
 	repoID := 1324
-
+	ctx := context.Background()
 	downloader := &GitlabDownloader{
-		ctx:    context.Background(),
 		client: client,
 		repoID: repoID,
 	}
@@ -465,7 +464,7 @@ func TestGitlabGetReviews(t *testing.T) {
 		mux.HandleFunc(fmt.Sprintf("/api/v4/projects/%d/merge_requests/%d/approvals", testCase.repoID, testCase.prID), mock)
 
 		id := int64(testCase.prID)
-		rvs, err := downloader.GetReviews(&base.Issue{Number: id, ForeignIndex: id})
+		rvs, err := downloader.GetReviews(ctx, &base.Issue{Number: id, ForeignIndex: id})
 		assert.NoError(t, err)
 		assertReviewsEqual(t, []*base.Review{&review}, rvs)
 	}
diff --git a/services/migrations/gogs.go b/services/migrations/gogs.go
index 72c52d180b..a4f84dbf72 100644
--- a/services/migrations/gogs.go
+++ b/services/migrations/gogs.go
@@ -13,7 +13,6 @@ import (
 
 	"code.gitea.io/gitea/modules/log"
 	base "code.gitea.io/gitea/modules/migration"
-	"code.gitea.io/gitea/modules/proxy"
 	"code.gitea.io/gitea/modules/structs"
 
 	"github.com/gogs/go-gogs-client"
@@ -60,16 +59,14 @@ func (f *GogsDownloaderFactory) GitServiceType() structs.GitServiceType {
 // from gogs via API
 type GogsDownloader struct {
 	base.NullDownloader
-	ctx                context.Context
-	client             *gogs.Client
 	baseURL            string
 	repoOwner          string
 	repoName           string
 	userName           string
 	password           string
+	token              string
 	openIssuesFinished bool
 	openIssuesPages    int
-	transport          http.RoundTripper
 }
 
 // String implements Stringer
@@ -84,53 +81,45 @@ func (g *GogsDownloader) LogString() string {
 	return fmt.Sprintf("<GogsDownloader %s %s/%s>", g.baseURL, g.repoOwner, g.repoName)
 }
 
-// SetContext set context
-func (g *GogsDownloader) SetContext(ctx context.Context) {
-	g.ctx = ctx
-}
-
 // NewGogsDownloader creates a gogs Downloader via gogs API
-func NewGogsDownloader(ctx context.Context, baseURL, userName, password, token, repoOwner, repoName string) *GogsDownloader {
+func NewGogsDownloader(_ context.Context, baseURL, userName, password, token, repoOwner, repoName string) *GogsDownloader {
 	downloader := GogsDownloader{
-		ctx:       ctx,
 		baseURL:   baseURL,
 		userName:  userName,
 		password:  password,
+		token:     token,
 		repoOwner: repoOwner,
 		repoName:  repoName,
 	}
-
-	var client *gogs.Client
-	if len(token) != 0 {
-		client = gogs.NewClient(baseURL, token)
-		downloader.userName = token
-	} else {
-		transport := NewMigrationHTTPTransport()
-		transport.Proxy = func(req *http.Request) (*url.URL, error) {
-			req.SetBasicAuth(userName, password)
-			return proxy.Proxy()(req)
-		}
-		downloader.transport = transport
-
-		client = gogs.NewClient(baseURL, "")
-		client.SetHTTPClient(&http.Client{
-			Transport: &downloader,
-		})
-	}
-
-	downloader.client = client
 	return &downloader
 }
 
-// RoundTrip wraps the provided request within this downloader's context and passes it to our internal http.Transport.
-// This implements http.RoundTripper and makes the gogs client requests cancellable even though it is not cancellable itself
-func (g *GogsDownloader) RoundTrip(req *http.Request) (*http.Response, error) {
-	return g.transport.RoundTrip(req.WithContext(g.ctx))
+type roundTripperFunc func(req *http.Request) (*http.Response, error)
+
+func (rt roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
+	return rt(r)
+}
+
+func (g *GogsDownloader) client(ctx context.Context) *gogs.Client {
+	// Gogs client lacks the context support, so we use a custom transport
+	// Then each request uses a dedicated client with its own context
+	httpTransport := NewMigrationHTTPTransport()
+	gogsClient := gogs.NewClient(g.baseURL, g.token)
+	gogsClient.SetHTTPClient(&http.Client{
+		Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+			if g.password != "" {
+				// Gogs client lacks the support for basic auth, this is the only way to set it
+				req.SetBasicAuth(g.userName, g.password)
+			}
+			return httpTransport.RoundTrip(req.WithContext(ctx))
+		}),
+	})
+	return gogsClient
 }
 
 // GetRepoInfo returns a repository information
-func (g *GogsDownloader) GetRepoInfo() (*base.Repository, error) {
-	gr, err := g.client.GetRepo(g.repoOwner, g.repoName)
+func (g *GogsDownloader) GetRepoInfo(ctx context.Context) (*base.Repository, error) {
+	gr, err := g.client(ctx).GetRepo(g.repoOwner, g.repoName)
 	if err != nil {
 		return nil, err
 	}
@@ -148,11 +137,11 @@ func (g *GogsDownloader) GetRepoInfo() (*base.Repository, error) {
 }
 
 // GetMilestones returns milestones
-func (g *GogsDownloader) GetMilestones() ([]*base.Milestone, error) {
+func (g *GogsDownloader) GetMilestones(ctx context.Context) ([]*base.Milestone, error) {
 	perPage := 100
 	milestones := make([]*base.Milestone, 0, perPage)
 
-	ms, err := g.client.ListRepoMilestones(g.repoOwner, g.repoName)
+	ms, err := g.client(ctx).ListRepoMilestones(g.repoOwner, g.repoName)
 	if err != nil {
 		return nil, err
 	}
@@ -171,10 +160,10 @@ func (g *GogsDownloader) GetMilestones() ([]*base.Milestone, error) {
 }
 
 // GetLabels returns labels
-func (g *GogsDownloader) GetLabels() ([]*base.Label, error) {
+func (g *GogsDownloader) GetLabels(ctx context.Context) ([]*base.Label, error) {
 	perPage := 100
 	labels := make([]*base.Label, 0, perPage)
-	ls, err := g.client.ListRepoLabels(g.repoOwner, g.repoName)
+	ls, err := g.client(ctx).ListRepoLabels(g.repoOwner, g.repoName)
 	if err != nil {
 		return nil, err
 	}
@@ -187,7 +176,7 @@ func (g *GogsDownloader) GetLabels() ([]*base.Label, error) {
 }
 
 // GetIssues returns issues according start and limit, perPage is not supported
-func (g *GogsDownloader) GetIssues(page, _ int) ([]*base.Issue, bool, error) {
+func (g *GogsDownloader) GetIssues(ctx context.Context, page, _ int) ([]*base.Issue, bool, error) {
 	var state string
 	if g.openIssuesFinished {
 		state = string(gogs.STATE_CLOSED)
@@ -197,7 +186,7 @@ func (g *GogsDownloader) GetIssues(page, _ int) ([]*base.Issue, bool, error) {
 		g.openIssuesPages = page
 	}
 
-	issues, isEnd, err := g.getIssues(page, state)
+	issues, isEnd, err := g.getIssues(ctx, page, state)
 	if err != nil {
 		return nil, false, err
 	}
@@ -212,10 +201,10 @@ func (g *GogsDownloader) GetIssues(page, _ int) ([]*base.Issue, bool, error) {
 	return issues, false, nil
 }
 
-func (g *GogsDownloader) getIssues(page int, state string) ([]*base.Issue, bool, error) {
+func (g *GogsDownloader) getIssues(ctx context.Context, page int, state string) ([]*base.Issue, bool, error) {
 	allIssues := make([]*base.Issue, 0, 10)
 
-	issues, err := g.client.ListRepoIssues(g.repoOwner, g.repoName, gogs.ListIssueOption{
+	issues, err := g.client(ctx).ListRepoIssues(g.repoOwner, g.repoName, gogs.ListIssueOption{
 		Page:  page,
 		State: state,
 	})
@@ -234,10 +223,10 @@ func (g *GogsDownloader) getIssues(page int, state string) ([]*base.Issue, bool,
 }
 
 // GetComments returns comments according issueNumber
-func (g *GogsDownloader) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
+func (g *GogsDownloader) GetComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
 	allComments := make([]*base.Comment, 0, 100)
 
-	comments, err := g.client.ListIssueComments(g.repoOwner, g.repoName, commentable.GetForeignIndex())
+	comments, err := g.client(ctx).ListIssueComments(g.repoOwner, g.repoName, commentable.GetForeignIndex())
 	if err != nil {
 		return nil, false, fmt.Errorf("error while listing repos: %w", err)
 	}
@@ -261,7 +250,7 @@ func (g *GogsDownloader) GetComments(commentable base.Commentable) ([]*base.Comm
 }
 
 // GetTopics return repository topics
-func (g *GogsDownloader) GetTopics() ([]string, error) {
+func (g *GogsDownloader) GetTopics(_ context.Context) ([]string, error) {
 	return []string{}, nil
 }
 
diff --git a/services/migrations/gogs_test.go b/services/migrations/gogs_test.go
index 610af183de..91c36bdcc6 100644
--- a/services/migrations/gogs_test.go
+++ b/services/migrations/gogs_test.go
@@ -28,9 +28,9 @@ func TestGogsDownloadRepo(t *testing.T) {
 		t.Skipf("visit test repo failed, ignored")
 		return
 	}
-
-	downloader := NewGogsDownloader(context.Background(), "https://try.gogs.io", "", "", gogsPersonalAccessToken, "lunnytest", "TESTREPO")
-	repo, err := downloader.GetRepoInfo()
+	ctx := context.Background()
+	downloader := NewGogsDownloader(ctx, "https://try.gogs.io", "", "", gogsPersonalAccessToken, "lunnytest", "TESTREPO")
+	repo, err := downloader.GetRepoInfo(ctx)
 	assert.NoError(t, err)
 
 	assertRepositoryEqual(t, &base.Repository{
@@ -42,7 +42,7 @@ func TestGogsDownloadRepo(t *testing.T) {
 		DefaultBranch: "master",
 	}, repo)
 
-	milestones, err := downloader.GetMilestones()
+	milestones, err := downloader.GetMilestones(ctx)
 	assert.NoError(t, err)
 	assertMilestonesEqual(t, []*base.Milestone{
 		{
@@ -51,7 +51,7 @@ func TestGogsDownloadRepo(t *testing.T) {
 		},
 	}, milestones)
 
-	labels, err := downloader.GetLabels()
+	labels, err := downloader.GetLabels(ctx)
 	assert.NoError(t, err)
 	assertLabelsEqual(t, []*base.Label{
 		{
@@ -85,7 +85,7 @@ func TestGogsDownloadRepo(t *testing.T) {
 	}, labels)
 
 	// downloader.GetIssues()
-	issues, isEnd, err := downloader.GetIssues(1, 8)
+	issues, isEnd, err := downloader.GetIssues(ctx, 1, 8)
 	assert.NoError(t, err)
 	assert.False(t, isEnd)
 	assertIssuesEqual(t, []*base.Issue{
@@ -110,7 +110,7 @@ func TestGogsDownloadRepo(t *testing.T) {
 	}, issues)
 
 	// downloader.GetComments()
-	comments, _, err := downloader.GetComments(&base.Issue{Number: 1, ForeignIndex: 1})
+	comments, _, err := downloader.GetComments(ctx, &base.Issue{Number: 1, ForeignIndex: 1})
 	assert.NoError(t, err)
 	assertCommentsEqual(t, []*base.Comment{
 		{
@@ -134,6 +134,6 @@ func TestGogsDownloadRepo(t *testing.T) {
 	}, comments)
 
 	// downloader.GetPullRequests()
-	_, _, err = downloader.GetPullRequests(1, 3)
+	_, _, err = downloader.GetPullRequests(ctx, 1, 3)
 	assert.Error(t, err)
 }
diff --git a/services/migrations/migrate.go b/services/migrations/migrate.go
index 4914c4cb35..876fbca4e5 100644
--- a/services/migrations/migrate.go
+++ b/services/migrations/migrate.go
@@ -177,12 +177,12 @@ func newDownloader(ctx context.Context, ownerName string, opts base.MigrateOptio
 // migrateRepository will download information and then upload it to Uploader, this is a simple
 // process for small repository. For a big repository, save all the data to disk
 // before upload is better
-func migrateRepository(_ context.Context, doer *user_model.User, downloader base.Downloader, uploader base.Uploader, opts base.MigrateOptions, messenger base.Messenger) error {
+func migrateRepository(ctx context.Context, doer *user_model.User, downloader base.Downloader, uploader base.Uploader, opts base.MigrateOptions, messenger base.Messenger) error {
 	if messenger == nil {
 		messenger = base.NilMessenger
 	}
 
-	repo, err := downloader.GetRepoInfo()
+	repo, err := downloader.GetRepoInfo(ctx)
 	if err != nil {
 		if !base.IsErrNotSupported(err) {
 			return err
@@ -221,14 +221,14 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 
 	log.Trace("migrating git data from %s", repo.CloneURL)
 	messenger("repo.migrate.migrating_git")
-	if err = uploader.CreateRepo(repo, opts); err != nil {
+	if err = uploader.CreateRepo(ctx, repo, opts); err != nil {
 		return err
 	}
 	defer uploader.Close()
 
 	log.Trace("migrating topics")
 	messenger("repo.migrate.migrating_topics")
-	topics, err := downloader.GetTopics()
+	topics, err := downloader.GetTopics(ctx)
 	if err != nil {
 		if !base.IsErrNotSupported(err) {
 			return err
@@ -236,7 +236,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 		log.Warn("migrating topics is not supported, ignored")
 	}
 	if len(topics) != 0 {
-		if err = uploader.CreateTopics(topics...); err != nil {
+		if err = uploader.CreateTopics(ctx, topics...); err != nil {
 			return err
 		}
 	}
@@ -244,7 +244,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 	if opts.Milestones {
 		log.Trace("migrating milestones")
 		messenger("repo.migrate.migrating_milestones")
-		milestones, err := downloader.GetMilestones()
+		milestones, err := downloader.GetMilestones(ctx)
 		if err != nil {
 			if !base.IsErrNotSupported(err) {
 				return err
@@ -257,7 +257,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 				msBatchSize = len(milestones)
 			}
 
-			if err := uploader.CreateMilestones(milestones[:msBatchSize]...); err != nil {
+			if err := uploader.CreateMilestones(ctx, milestones[:msBatchSize]...); err != nil {
 				return err
 			}
 			milestones = milestones[msBatchSize:]
@@ -267,7 +267,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 	if opts.Labels {
 		log.Trace("migrating labels")
 		messenger("repo.migrate.migrating_labels")
-		labels, err := downloader.GetLabels()
+		labels, err := downloader.GetLabels(ctx)
 		if err != nil {
 			if !base.IsErrNotSupported(err) {
 				return err
@@ -281,7 +281,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 				lbBatchSize = len(labels)
 			}
 
-			if err := uploader.CreateLabels(labels[:lbBatchSize]...); err != nil {
+			if err := uploader.CreateLabels(ctx, labels[:lbBatchSize]...); err != nil {
 				return err
 			}
 			labels = labels[lbBatchSize:]
@@ -291,7 +291,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 	if opts.Releases {
 		log.Trace("migrating releases")
 		messenger("repo.migrate.migrating_releases")
-		releases, err := downloader.GetReleases()
+		releases, err := downloader.GetReleases(ctx)
 		if err != nil {
 			if !base.IsErrNotSupported(err) {
 				return err
@@ -305,14 +305,14 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 				relBatchSize = len(releases)
 			}
 
-			if err = uploader.CreateReleases(releases[:relBatchSize]...); err != nil {
+			if err = uploader.CreateReleases(ctx, releases[:relBatchSize]...); err != nil {
 				return err
 			}
 			releases = releases[relBatchSize:]
 		}
 
 		// Once all releases (if any) are inserted, sync any remaining non-release tags
-		if err = uploader.SyncTags(); err != nil {
+		if err = uploader.SyncTags(ctx); err != nil {
 			return err
 		}
 	}
@@ -333,7 +333,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 		// some of issue maybe duplicated, so we need to record the inserted issue indexes
 		mapInsertedIssueIndexes := container.Set[int64]{}
 		for i := 1; ; i++ {
-			issues, isEnd, err := downloader.GetIssues(i, issueBatchSize)
+			issues, isEnd, err := downloader.GetIssues(ctx, i, issueBatchSize)
 			if err != nil {
 				if !base.IsErrNotSupported(err) {
 					return err
@@ -350,7 +350,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 				mapInsertedIssueIndexes.Add(issues[i].Number)
 			}
 
-			if err := uploader.CreateIssues(issues...); err != nil {
+			if err := uploader.CreateIssues(ctx, issues...); err != nil {
 				return err
 			}
 
@@ -358,7 +358,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 				allComments := make([]*base.Comment, 0, commentBatchSize)
 				for _, issue := range issues {
 					log.Trace("migrating issue %d's comments", issue.Number)
-					comments, _, err := downloader.GetComments(issue)
+					comments, _, err := downloader.GetComments(ctx, issue)
 					if err != nil {
 						if !base.IsErrNotSupported(err) {
 							return err
@@ -369,7 +369,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 					allComments = append(allComments, comments...)
 
 					if len(allComments) >= commentBatchSize {
-						if err = uploader.CreateComments(allComments[:commentBatchSize]...); err != nil {
+						if err = uploader.CreateComments(ctx, allComments[:commentBatchSize]...); err != nil {
 							return err
 						}
 
@@ -378,7 +378,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 				}
 
 				if len(allComments) > 0 {
-					if err = uploader.CreateComments(allComments...); err != nil {
+					if err = uploader.CreateComments(ctx, allComments...); err != nil {
 						return err
 					}
 				}
@@ -396,7 +396,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 		prBatchSize := uploader.MaxBatchInsertSize("pullrequest")
 		mapInsertedIssueIndexes := container.Set[int64]{}
 		for i := 1; ; i++ {
-			prs, isEnd, err := downloader.GetPullRequests(i, prBatchSize)
+			prs, isEnd, err := downloader.GetPullRequests(ctx, i, prBatchSize)
 			if err != nil {
 				if !base.IsErrNotSupported(err) {
 					return err
@@ -413,7 +413,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 				mapInsertedIssueIndexes.Add(prs[i].Number)
 			}
 
-			if err := uploader.CreatePullRequests(prs...); err != nil {
+			if err := uploader.CreatePullRequests(ctx, prs...); err != nil {
 				return err
 			}
 
@@ -423,7 +423,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 					allComments := make([]*base.Comment, 0, commentBatchSize)
 					for _, pr := range prs {
 						log.Trace("migrating pull request %d's comments", pr.Number)
-						comments, _, err := downloader.GetComments(pr)
+						comments, _, err := downloader.GetComments(ctx, pr)
 						if err != nil {
 							if !base.IsErrNotSupported(err) {
 								return err
@@ -434,14 +434,14 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 						allComments = append(allComments, comments...)
 
 						if len(allComments) >= commentBatchSize {
-							if err = uploader.CreateComments(allComments[:commentBatchSize]...); err != nil {
+							if err = uploader.CreateComments(ctx, allComments[:commentBatchSize]...); err != nil {
 								return err
 							}
 							allComments = allComments[commentBatchSize:]
 						}
 					}
 					if len(allComments) > 0 {
-						if err = uploader.CreateComments(allComments...); err != nil {
+						if err = uploader.CreateComments(ctx, allComments...); err != nil {
 							return err
 						}
 					}
@@ -450,7 +450,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 				// migrate reviews
 				allReviews := make([]*base.Review, 0, reviewBatchSize)
 				for _, pr := range prs {
-					reviews, err := downloader.GetReviews(pr)
+					reviews, err := downloader.GetReviews(ctx, pr)
 					if err != nil {
 						if !base.IsErrNotSupported(err) {
 							return err
@@ -462,14 +462,14 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 					allReviews = append(allReviews, reviews...)
 
 					if len(allReviews) >= reviewBatchSize {
-						if err = uploader.CreateReviews(allReviews[:reviewBatchSize]...); err != nil {
+						if err = uploader.CreateReviews(ctx, allReviews[:reviewBatchSize]...); err != nil {
 							return err
 						}
 						allReviews = allReviews[reviewBatchSize:]
 					}
 				}
 				if len(allReviews) > 0 {
-					if err = uploader.CreateReviews(allReviews...); err != nil {
+					if err = uploader.CreateReviews(ctx, allReviews...); err != nil {
 						return err
 					}
 				}
@@ -484,12 +484,12 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 	if opts.Comments && supportAllComments {
 		log.Trace("migrating comments")
 		for i := 1; ; i++ {
-			comments, isEnd, err := downloader.GetAllComments(i, commentBatchSize)
+			comments, isEnd, err := downloader.GetAllComments(ctx, i, commentBatchSize)
 			if err != nil {
 				return err
 			}
 
-			if err := uploader.CreateComments(comments...); err != nil {
+			if err := uploader.CreateComments(ctx, comments...); err != nil {
 				return err
 			}
 
@@ -499,7 +499,7 @@ func migrateRepository(_ context.Context, doer *user_model.User, downloader base
 		}
 	}
 
-	return uploader.Finish()
+	return uploader.Finish(ctx)
 }
 
 // Init migrations service
diff --git a/services/migrations/onedev.go b/services/migrations/onedev.go
index e2f7b771f3..4ce35dd12e 100644
--- a/services/migrations/onedev.go
+++ b/services/migrations/onedev.go
@@ -71,7 +71,6 @@ type onedevUser struct {
 // from OneDev
 type OneDevDownloader struct {
 	base.NullDownloader
-	ctx           context.Context
 	client        *http.Client
 	baseURL       *url.URL
 	repoName      string
@@ -81,15 +80,9 @@ type OneDevDownloader struct {
 	milestoneMap  map[int64]string
 }
 
-// SetContext set context
-func (d *OneDevDownloader) SetContext(ctx context.Context) {
-	d.ctx = ctx
-}
-
 // NewOneDevDownloader creates a new downloader
-func NewOneDevDownloader(ctx context.Context, baseURL *url.URL, username, password, repoName string) *OneDevDownloader {
+func NewOneDevDownloader(_ context.Context, baseURL *url.URL, username, password, repoName string) *OneDevDownloader {
 	downloader := &OneDevDownloader{
-		ctx:      ctx,
 		baseURL:  baseURL,
 		repoName: repoName,
 		client: &http.Client{
@@ -121,7 +114,7 @@ func (d *OneDevDownloader) LogString() string {
 	return fmt.Sprintf("<OneDevDownloader %s [%d]/%s>", d.baseURL, d.repoID, d.repoName)
 }
 
-func (d *OneDevDownloader) callAPI(endpoint string, parameter map[string]string, result any) error {
+func (d *OneDevDownloader) callAPI(ctx context.Context, endpoint string, parameter map[string]string, result any) error {
 	u, err := d.baseURL.Parse(endpoint)
 	if err != nil {
 		return err
@@ -135,7 +128,7 @@ func (d *OneDevDownloader) callAPI(endpoint string, parameter map[string]string,
 		u.RawQuery = query.Encode()
 	}
 
-	req, err := http.NewRequestWithContext(d.ctx, "GET", u.String(), nil)
+	req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
 	if err != nil {
 		return err
 	}
@@ -151,7 +144,7 @@ func (d *OneDevDownloader) callAPI(endpoint string, parameter map[string]string,
 }
 
 // GetRepoInfo returns repository information
-func (d *OneDevDownloader) GetRepoInfo() (*base.Repository, error) {
+func (d *OneDevDownloader) GetRepoInfo(ctx context.Context) (*base.Repository, error) {
 	info := make([]struct {
 		ID          int64  `json:"id"`
 		Name        string `json:"name"`
@@ -159,6 +152,7 @@ func (d *OneDevDownloader) GetRepoInfo() (*base.Repository, error) {
 	}, 0, 1)
 
 	err := d.callAPI(
+		ctx,
 		"/api/projects",
 		map[string]string{
 			"query":  `"Name" is "` + d.repoName + `"`,
@@ -194,7 +188,7 @@ func (d *OneDevDownloader) GetRepoInfo() (*base.Repository, error) {
 }
 
 // GetMilestones returns milestones
-func (d *OneDevDownloader) GetMilestones() ([]*base.Milestone, error) {
+func (d *OneDevDownloader) GetMilestones(ctx context.Context) ([]*base.Milestone, error) {
 	rawMilestones := make([]struct {
 		ID          int64      `json:"id"`
 		Name        string     `json:"name"`
@@ -209,6 +203,7 @@ func (d *OneDevDownloader) GetMilestones() ([]*base.Milestone, error) {
 	offset := 0
 	for {
 		err := d.callAPI(
+			ctx,
 			endpoint,
 			map[string]string{
 				"offset": strconv.Itoa(offset),
@@ -243,7 +238,7 @@ func (d *OneDevDownloader) GetMilestones() ([]*base.Milestone, error) {
 }
 
 // GetLabels returns labels
-func (d *OneDevDownloader) GetLabels() ([]*base.Label, error) {
+func (d *OneDevDownloader) GetLabels(_ context.Context) ([]*base.Label, error) {
 	return []*base.Label{
 		{
 			Name:  "Bug",
@@ -277,7 +272,7 @@ type onedevIssueContext struct {
 }
 
 // GetIssues returns issues
-func (d *OneDevDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
+func (d *OneDevDownloader) GetIssues(ctx context.Context, page, perPage int) ([]*base.Issue, bool, error) {
 	rawIssues := make([]struct {
 		ID          int64     `json:"id"`
 		Number      int64     `json:"number"`
@@ -289,6 +284,7 @@ func (d *OneDevDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er
 	}, 0, perPage)
 
 	err := d.callAPI(
+		ctx,
 		"/api/issues",
 		map[string]string{
 			"query":  `"Project" is "` + d.repoName + `"`,
@@ -308,6 +304,7 @@ func (d *OneDevDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er
 			Value string `json:"value"`
 		}, 0, 10)
 		err := d.callAPI(
+			ctx,
 			fmt.Sprintf("/api/issues/%d/fields", issue.ID),
 			nil,
 			&fields,
@@ -329,6 +326,7 @@ func (d *OneDevDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er
 			Name string `json:"name"`
 		}, 0, 10)
 		err = d.callAPI(
+			ctx,
 			fmt.Sprintf("/api/issues/%d/milestones", issue.ID),
 			nil,
 			&milestones,
@@ -345,7 +343,7 @@ func (d *OneDevDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er
 		if state == "released" {
 			state = "closed"
 		}
-		poster := d.tryGetUser(issue.SubmitterID)
+		poster := d.tryGetUser(ctx, issue.SubmitterID)
 		issues = append(issues, &base.Issue{
 			Title:        issue.Title,
 			Number:       issue.Number,
@@ -370,7 +368,7 @@ func (d *OneDevDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er
 }
 
 // GetComments returns comments
-func (d *OneDevDownloader) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
+func (d *OneDevDownloader) GetComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
 	context, ok := commentable.GetContext().(onedevIssueContext)
 	if !ok {
 		return nil, false, fmt.Errorf("unexpected context: %+v", commentable.GetContext())
@@ -391,6 +389,7 @@ func (d *OneDevDownloader) GetComments(commentable base.Commentable) ([]*base.Co
 	}
 
 	err := d.callAPI(
+		ctx,
 		endpoint,
 		nil,
 		&rawComments,
@@ -412,6 +411,7 @@ func (d *OneDevDownloader) GetComments(commentable base.Commentable) ([]*base.Co
 	}
 
 	err = d.callAPI(
+		ctx,
 		endpoint,
 		nil,
 		&rawChanges,
@@ -425,7 +425,7 @@ func (d *OneDevDownloader) GetComments(commentable base.Commentable) ([]*base.Co
 		if len(comment.Content) == 0 {
 			continue
 		}
-		poster := d.tryGetUser(comment.UserID)
+		poster := d.tryGetUser(ctx, comment.UserID)
 		comments = append(comments, &base.Comment{
 			IssueIndex:  commentable.GetLocalIndex(),
 			Index:       comment.ID,
@@ -450,7 +450,7 @@ func (d *OneDevDownloader) GetComments(commentable base.Commentable) ([]*base.Co
 			continue
 		}
 
-		poster := d.tryGetUser(change.UserID)
+		poster := d.tryGetUser(ctx, change.UserID)
 		comments = append(comments, &base.Comment{
 			IssueIndex:  commentable.GetLocalIndex(),
 			PosterID:    poster.ID,
@@ -466,7 +466,7 @@ func (d *OneDevDownloader) GetComments(commentable base.Commentable) ([]*base.Co
 }
 
 // GetPullRequests returns pull requests
-func (d *OneDevDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
+func (d *OneDevDownloader) GetPullRequests(ctx context.Context, page, perPage int) ([]*base.PullRequest, bool, error) {
 	rawPullRequests := make([]struct {
 		ID             int64     `json:"id"`
 		Number         int64     `json:"number"`
@@ -484,6 +484,7 @@ func (d *OneDevDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
 	}, 0, perPage)
 
 	err := d.callAPI(
+		ctx,
 		"/api/pull-requests",
 		map[string]string{
 			"query":  `"Target Project" is "` + d.repoName + `"`,
@@ -505,6 +506,7 @@ func (d *OneDevDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
 			MergeCommitHash      string `json:"mergeCommitHash"`
 		}
 		err := d.callAPI(
+			ctx,
 			fmt.Sprintf("/api/pull-requests/%d/merge-preview", pr.ID),
 			nil,
 			&mergePreview,
@@ -525,7 +527,7 @@ func (d *OneDevDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
 				mergedTime = pr.CloseInfo.Date
 			}
 		}
-		poster := d.tryGetUser(pr.SubmitterID)
+		poster := d.tryGetUser(ctx, pr.SubmitterID)
 
 		number := pr.Number + d.maxIssueIndex
 		pullRequests = append(pullRequests, &base.PullRequest{
@@ -562,7 +564,7 @@ func (d *OneDevDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
 }
 
 // GetReviews returns pull requests reviews
-func (d *OneDevDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Review, error) {
+func (d *OneDevDownloader) GetReviews(ctx context.Context, reviewable base.Reviewable) ([]*base.Review, error) {
 	rawReviews := make([]struct {
 		ID     int64 `json:"id"`
 		UserID int64 `json:"userId"`
@@ -574,6 +576,7 @@ func (d *OneDevDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Revie
 	}, 0, 100)
 
 	err := d.callAPI(
+		ctx,
 		fmt.Sprintf("/api/pull-requests/%d/reviews", reviewable.GetForeignIndex()),
 		nil,
 		&rawReviews,
@@ -596,7 +599,7 @@ func (d *OneDevDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Revie
 			}
 		}
 
-		poster := d.tryGetUser(review.UserID)
+		poster := d.tryGetUser(ctx, review.UserID)
 		reviews = append(reviews, &base.Review{
 			IssueIndex:   reviewable.GetLocalIndex(),
 			ReviewerID:   poster.ID,
@@ -610,14 +613,15 @@ func (d *OneDevDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Revie
 }
 
 // GetTopics return repository topics
-func (d *OneDevDownloader) GetTopics() ([]string, error) {
+func (d *OneDevDownloader) GetTopics(_ context.Context) ([]string, error) {
 	return []string{}, nil
 }
 
-func (d *OneDevDownloader) tryGetUser(userID int64) *onedevUser {
+func (d *OneDevDownloader) tryGetUser(ctx context.Context, userID int64) *onedevUser {
 	user, ok := d.userMap[userID]
 	if !ok {
 		err := d.callAPI(
+			ctx,
 			fmt.Sprintf("/api/users/%d", userID),
 			nil,
 			&user,
diff --git a/services/migrations/onedev_test.go b/services/migrations/onedev_test.go
index 48412fec64..0a4b05446d 100644
--- a/services/migrations/onedev_test.go
+++ b/services/migrations/onedev_test.go
@@ -22,11 +22,12 @@ func TestOneDevDownloadRepo(t *testing.T) {
 	}
 
 	u, _ := url.Parse("https://code.onedev.io")
-	downloader := NewOneDevDownloader(context.Background(), u, "", "", "go-gitea-test_repo")
+	ctx := context.Background()
+	downloader := NewOneDevDownloader(ctx, u, "", "", "go-gitea-test_repo")
 	if err != nil {
 		t.Fatalf("NewOneDevDownloader is nil: %v", err)
 	}
-	repo, err := downloader.GetRepoInfo()
+	repo, err := downloader.GetRepoInfo(ctx)
 	assert.NoError(t, err)
 	assertRepositoryEqual(t, &base.Repository{
 		Name:        "go-gitea-test_repo",
@@ -36,7 +37,7 @@ func TestOneDevDownloadRepo(t *testing.T) {
 		OriginalURL: "https://code.onedev.io/projects/go-gitea-test_repo",
 	}, repo)
 
-	milestones, err := downloader.GetMilestones()
+	milestones, err := downloader.GetMilestones(ctx)
 	assert.NoError(t, err)
 	deadline := time.Unix(1620086400, 0)
 	assertMilestonesEqual(t, []*base.Milestone{
@@ -51,11 +52,11 @@ func TestOneDevDownloadRepo(t *testing.T) {
 		},
 	}, milestones)
 
-	labels, err := downloader.GetLabels()
+	labels, err := downloader.GetLabels(ctx)
 	assert.NoError(t, err)
 	assert.Len(t, labels, 6)
 
-	issues, isEnd, err := downloader.GetIssues(1, 2)
+	issues, isEnd, err := downloader.GetIssues(ctx, 1, 2)
 	assert.NoError(t, err)
 	assert.False(t, isEnd)
 	assertIssuesEqual(t, []*base.Issue{
@@ -94,7 +95,7 @@ func TestOneDevDownloadRepo(t *testing.T) {
 		},
 	}, issues)
 
-	comments, _, err := downloader.GetComments(&base.Issue{
+	comments, _, err := downloader.GetComments(ctx, &base.Issue{
 		Number:       4,
 		ForeignIndex: 398,
 		Context:      onedevIssueContext{IsPullRequest: false},
@@ -110,7 +111,7 @@ func TestOneDevDownloadRepo(t *testing.T) {
 		},
 	}, comments)
 
-	prs, _, err := downloader.GetPullRequests(1, 1)
+	prs, _, err := downloader.GetPullRequests(ctx, 1, 1)
 	assert.NoError(t, err)
 	assertPullRequestsEqual(t, []*base.PullRequest{
 		{
@@ -136,7 +137,7 @@ func TestOneDevDownloadRepo(t *testing.T) {
 		},
 	}, prs)
 
-	rvs, err := downloader.GetReviews(&base.PullRequest{Number: 5, ForeignIndex: 186})
+	rvs, err := downloader.GetReviews(ctx, &base.PullRequest{Number: 5, ForeignIndex: 186})
 	assert.NoError(t, err)
 	assertReviewsEqual(t, []*base.Review{
 		{
diff --git a/services/migrations/restore.go b/services/migrations/restore.go
index fd337b22c7..5686285935 100644
--- a/services/migrations/restore.go
+++ b/services/migrations/restore.go
@@ -18,7 +18,6 @@ import (
 // RepositoryRestorer implements an Downloader from the local directory
 type RepositoryRestorer struct {
 	base.NullDownloader
-	ctx        context.Context
 	baseDir    string
 	repoOwner  string
 	repoName   string
@@ -26,13 +25,12 @@ type RepositoryRestorer struct {
 }
 
 // NewRepositoryRestorer creates a repository restorer which could restore repository from a dumped folder
-func NewRepositoryRestorer(ctx context.Context, baseDir, owner, repoName string, validation bool) (*RepositoryRestorer, error) {
+func NewRepositoryRestorer(_ context.Context, baseDir, owner, repoName string, validation bool) (*RepositoryRestorer, error) {
 	baseDir, err := filepath.Abs(baseDir)
 	if err != nil {
 		return nil, err
 	}
 	return &RepositoryRestorer{
-		ctx:        ctx,
 		baseDir:    baseDir,
 		repoOwner:  owner,
 		repoName:   repoName,
@@ -48,11 +46,6 @@ func (r *RepositoryRestorer) reviewDir() string {
 	return filepath.Join(r.baseDir, "reviews")
 }
 
-// SetContext set context
-func (r *RepositoryRestorer) SetContext(ctx context.Context) {
-	r.ctx = ctx
-}
-
 func (r *RepositoryRestorer) getRepoOptions() (map[string]string, error) {
 	p := filepath.Join(r.baseDir, "repo.yml")
 	bs, err := os.ReadFile(p)
@@ -69,7 +62,7 @@ func (r *RepositoryRestorer) getRepoOptions() (map[string]string, error) {
 }
 
 // GetRepoInfo returns a repository information
-func (r *RepositoryRestorer) GetRepoInfo() (*base.Repository, error) {
+func (r *RepositoryRestorer) GetRepoInfo(_ context.Context) (*base.Repository, error) {
 	opts, err := r.getRepoOptions()
 	if err != nil {
 		return nil, err
@@ -89,7 +82,7 @@ func (r *RepositoryRestorer) GetRepoInfo() (*base.Repository, error) {
 }
 
 // GetTopics return github topics
-func (r *RepositoryRestorer) GetTopics() ([]string, error) {
+func (r *RepositoryRestorer) GetTopics(_ context.Context) ([]string, error) {
 	p := filepath.Join(r.baseDir, "topic.yml")
 
 	topics := struct {
@@ -112,7 +105,7 @@ func (r *RepositoryRestorer) GetTopics() ([]string, error) {
 }
 
 // GetMilestones returns milestones
-func (r *RepositoryRestorer) GetMilestones() ([]*base.Milestone, error) {
+func (r *RepositoryRestorer) GetMilestones(_ context.Context) ([]*base.Milestone, error) {
 	milestones := make([]*base.Milestone, 0, 10)
 	p := filepath.Join(r.baseDir, "milestone.yml")
 	err := base.Load(p, &milestones, r.validation)
@@ -127,7 +120,7 @@ func (r *RepositoryRestorer) GetMilestones() ([]*base.Milestone, error) {
 }
 
 // GetReleases returns releases
-func (r *RepositoryRestorer) GetReleases() ([]*base.Release, error) {
+func (r *RepositoryRestorer) GetReleases(_ context.Context) ([]*base.Release, error) {
 	releases := make([]*base.Release, 0, 10)
 	p := filepath.Join(r.baseDir, "release.yml")
 	_, err := os.Stat(p)
@@ -158,7 +151,7 @@ func (r *RepositoryRestorer) GetReleases() ([]*base.Release, error) {
 }
 
 // GetLabels returns labels
-func (r *RepositoryRestorer) GetLabels() ([]*base.Label, error) {
+func (r *RepositoryRestorer) GetLabels(_ context.Context) ([]*base.Label, error) {
 	labels := make([]*base.Label, 0, 10)
 	p := filepath.Join(r.baseDir, "label.yml")
 	_, err := os.Stat(p)
@@ -182,7 +175,7 @@ func (r *RepositoryRestorer) GetLabels() ([]*base.Label, error) {
 }
 
 // GetIssues returns issues according start and limit
-func (r *RepositoryRestorer) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
+func (r *RepositoryRestorer) GetIssues(_ context.Context, _, _ int) ([]*base.Issue, bool, error) {
 	issues := make([]*base.Issue, 0, 10)
 	p := filepath.Join(r.baseDir, "issue.yml")
 	err := base.Load(p, &issues, r.validation)
@@ -196,7 +189,7 @@ func (r *RepositoryRestorer) GetIssues(page, perPage int) ([]*base.Issue, bool,
 }
 
 // GetComments returns comments according issueNumber
-func (r *RepositoryRestorer) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
+func (r *RepositoryRestorer) GetComments(_ context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
 	comments := make([]*base.Comment, 0, 10)
 	p := filepath.Join(r.commentDir(), fmt.Sprintf("%d.yml", commentable.GetForeignIndex()))
 	_, err := os.Stat(p)
@@ -220,7 +213,7 @@ func (r *RepositoryRestorer) GetComments(commentable base.Commentable) ([]*base.
 }
 
 // GetPullRequests returns pull requests according page and perPage
-func (r *RepositoryRestorer) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
+func (r *RepositoryRestorer) GetPullRequests(_ context.Context, page, perPage int) ([]*base.PullRequest, bool, error) {
 	pulls := make([]*base.PullRequest, 0, 10)
 	p := filepath.Join(r.baseDir, "pull_request.yml")
 	_, err := os.Stat(p)
@@ -248,7 +241,7 @@ func (r *RepositoryRestorer) GetPullRequests(page, perPage int) ([]*base.PullReq
 }
 
 // GetReviews returns pull requests review
-func (r *RepositoryRestorer) GetReviews(reviewable base.Reviewable) ([]*base.Review, error) {
+func (r *RepositoryRestorer) GetReviews(ctx context.Context, reviewable base.Reviewable) ([]*base.Review, error) {
 	reviews := make([]*base.Review, 0, 10)
 	p := filepath.Join(r.reviewDir(), fmt.Sprintf("%d.yml", reviewable.GetForeignIndex()))
 	_, err := os.Stat(p)
diff --git a/services/projects/issue.go b/services/projects/issue.go
index db1621a39f..6ca0f16806 100644
--- a/services/projects/issue.go
+++ b/services/projects/issue.go
@@ -55,22 +55,29 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
 				continue
 			}
 
-			_, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
+			projectColumnID, err := curIssue.ProjectColumnID(ctx)
 			if err != nil {
 				return err
 			}
 
-			// add timeline to issue
-			if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
-				Type:               issues_model.CommentTypeProjectColumn,
-				Doer:               doer,
-				Repo:               curIssue.Repo,
-				Issue:              curIssue,
-				ProjectID:          column.ProjectID,
-				ProjectTitle:       project.Title,
-				ProjectColumnID:    column.ID,
-				ProjectColumnTitle: column.Title,
-			}); err != nil {
+			if projectColumnID != column.ID {
+				// add timeline to issue
+				if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
+					Type:               issues_model.CommentTypeProjectColumn,
+					Doer:               doer,
+					Repo:               curIssue.Repo,
+					Issue:              curIssue,
+					ProjectID:          column.ProjectID,
+					ProjectTitle:       project.Title,
+					ProjectColumnID:    column.ID,
+					ProjectColumnTitle: column.Title,
+				}); err != nil {
+					return err
+				}
+			}
+
+			_, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
+			if err != nil {
 				return err
 			}
 		}
diff --git a/modules/gitgraph/graph.go b/services/repository/gitgraph/graph.go
similarity index 100%
rename from modules/gitgraph/graph.go
rename to services/repository/gitgraph/graph.go
diff --git a/modules/gitgraph/graph_models.go b/services/repository/gitgraph/graph_models.go
similarity index 100%
rename from modules/gitgraph/graph_models.go
rename to services/repository/gitgraph/graph_models.go
diff --git a/modules/gitgraph/graph_test.go b/services/repository/gitgraph/graph_test.go
similarity index 100%
rename from modules/gitgraph/graph_test.go
rename to services/repository/gitgraph/graph_test.go
diff --git a/modules/gitgraph/parser.go b/services/repository/gitgraph/parser.go
similarity index 100%
rename from modules/gitgraph/parser.go
rename to services/repository/gitgraph/parser.go
diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go
index 992b8c566f..3ea8f50764 100644
--- a/services/webhook/dingtalk.go
+++ b/services/webhook/dingtalk.go
@@ -170,6 +170,12 @@ func (dc dingtalkConvertor) Package(p *api.PackagePayload) (DingtalkPayload, err
 	return createDingtalkPayload(text, text, "view package", p.Package.HTMLURL), nil
 }
 
+func (dc dingtalkConvertor) Status(p *api.CommitStatusPayload) (DingtalkPayload, error) {
+	text, _ := getStatusPayloadInfo(p, noneLinkFormatter, true)
+
+	return createDingtalkPayload(text, text, "Status Changed", p.TargetURL), nil
+}
+
 func createDingtalkPayload(title, text, singleTitle, singleURL string) DingtalkPayload {
 	return DingtalkPayload{
 		MsgType: "actionCard",
diff --git a/services/webhook/discord.go b/services/webhook/discord.go
index 30d930062e..43e5e533bf 100644
--- a/services/webhook/discord.go
+++ b/services/webhook/discord.go
@@ -265,6 +265,12 @@ func (d discordConvertor) Package(p *api.PackagePayload) (DiscordPayload, error)
 	return d.createPayload(p.Sender, text, "", p.Package.HTMLURL, color), nil
 }
 
+func (d discordConvertor) Status(p *api.CommitStatusPayload) (DiscordPayload, error) {
+	text, color := getStatusPayloadInfo(p, noneLinkFormatter, false)
+
+	return d.createPayload(p.Sender, text, "", p.TargetURL, color), nil
+}
+
 func newDiscordRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
 	meta := &DiscordMeta{}
 	if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go
index 4e6aebc39d..639118d2a5 100644
--- a/services/webhook/feishu.go
+++ b/services/webhook/feishu.go
@@ -166,6 +166,12 @@ func (fc feishuConvertor) Package(p *api.PackagePayload) (FeishuPayload, error)
 	return newFeishuTextPayload(text), nil
 }
 
+func (fc feishuConvertor) Status(p *api.CommitStatusPayload) (FeishuPayload, error) {
+	text, _ := getStatusPayloadInfo(p, noneLinkFormatter, true)
+
+	return newFeishuTextPayload(text), nil
+}
+
 func newFeishuRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
 	var pc payloadConvertor[FeishuPayload] = feishuConvertor{}
 	return newJSONRequest(pc, w, t, true)
diff --git a/services/webhook/general.go b/services/webhook/general.go
index dde43bb349..91bf68600f 100644
--- a/services/webhook/general.go
+++ b/services/webhook/general.go
@@ -307,6 +307,18 @@ func getPackagePayloadInfo(p *api.PackagePayload, linkFormatter linkFormatter, w
 	return text, color
 }
 
+func getStatusPayloadInfo(p *api.CommitStatusPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) {
+	refLink := linkFormatter(p.TargetURL, p.Context+"["+p.SHA+"]:"+p.Description)
+
+	text = fmt.Sprintf("Commit Status changed: %s", refLink)
+	color = greenColor
+	if withSender {
+		text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName))
+	}
+
+	return text, color
+}
+
 // ToHook convert models.Webhook to api.Hook
 // This function is not part of the convert package to prevent an import cycle
 func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) {
diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go
index 96dfa139ac..ec21712837 100644
--- a/services/webhook/matrix.go
+++ b/services/webhook/matrix.go
@@ -244,6 +244,13 @@ func (m matrixConvertor) Package(p *api.PackagePayload) (MatrixPayload, error) {
 	return m.newPayload(text)
 }
 
+func (m matrixConvertor) Status(p *api.CommitStatusPayload) (MatrixPayload, error) {
+	refLink := htmlLinkFormatter(p.TargetURL, p.Context+"["+p.SHA+"]:"+p.Description)
+	text := fmt.Sprintf("Commit Status changed: %s", refLink)
+
+	return m.newPayload(text)
+}
+
 var urlRegex = regexp.MustCompile(`<a [^>]*?href="([^">]*?)">(.*?)</a>`)
 
 func getMessageBody(htmlText string) string {
diff --git a/services/webhook/msteams.go b/services/webhook/msteams.go
index 1ae7c4f931..485f695be2 100644
--- a/services/webhook/msteams.go
+++ b/services/webhook/msteams.go
@@ -303,6 +303,20 @@ func (m msteamsConvertor) Package(p *api.PackagePayload) (MSTeamsPayload, error)
 	), nil
 }
 
+func (m msteamsConvertor) Status(p *api.CommitStatusPayload) (MSTeamsPayload, error) {
+	title, color := getStatusPayloadInfo(p, noneLinkFormatter, false)
+
+	return createMSTeamsPayload(
+		p.Repo,
+		p.Sender,
+		title,
+		"",
+		p.TargetURL,
+		color,
+		&MSTeamsFact{"CommitStatus:", p.Context},
+	), nil
+}
+
 func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTarget string, color int, fact *MSTeamsFact) MSTeamsPayload {
 	facts := make([]MSTeamsFact, 0, 2)
 	if r != nil {
diff --git a/services/webhook/packagist.go b/services/webhook/packagist.go
index e66895832b..6864fc822a 100644
--- a/services/webhook/packagist.go
+++ b/services/webhook/packagist.go
@@ -110,6 +110,10 @@ func (pc packagistConvertor) Package(_ *api.PackagePayload) (PackagistPayload, e
 	return PackagistPayload{}, nil
 }
 
+func (pc packagistConvertor) Status(_ *api.CommitStatusPayload) (PackagistPayload, error) {
+	return PackagistPayload{}, nil
+}
+
 func newPackagistRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
 	meta := &PackagistMeta{}
 	if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
diff --git a/services/webhook/payloader.go b/services/webhook/payloader.go
index ab280a25b6..c29ad8ac92 100644
--- a/services/webhook/payloader.go
+++ b/services/webhook/payloader.go
@@ -28,6 +28,7 @@ type payloadConvertor[T any] interface {
 	Release(*api.ReleasePayload) (T, error)
 	Wiki(*api.WikiPayload) (T, error)
 	Package(*api.PackagePayload) (T, error)
+	Status(*api.CommitStatusPayload) (T, error)
 }
 
 func convertUnmarshalledJSON[T, P any](convert func(P) (T, error), data []byte) (t T, err error) {
@@ -77,6 +78,8 @@ func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module
 		return convertUnmarshalledJSON(rc.Wiki, data)
 	case webhook_module.HookEventPackage:
 		return convertUnmarshalledJSON(rc.Package, data)
+	case webhook_module.HookEventStatus:
+		return convertUnmarshalledJSON(rc.Status, data)
 	}
 	return t, fmt.Errorf("newPayload unsupported event: %s", event)
 }
diff --git a/services/webhook/slack.go b/services/webhook/slack.go
index 0371ee23e6..80ed747fd1 100644
--- a/services/webhook/slack.go
+++ b/services/webhook/slack.go
@@ -167,6 +167,12 @@ func (s slackConvertor) Package(p *api.PackagePayload) (SlackPayload, error) {
 	return s.createPayload(text, nil), nil
 }
 
+func (s slackConvertor) Status(p *api.CommitStatusPayload) (SlackPayload, error) {
+	text, _ := getStatusPayloadInfo(p, SlackLinkFormatter, true)
+
+	return s.createPayload(text, nil), nil
+}
+
 // Push implements payloadConvertor Push method
 func (s slackConvertor) Push(p *api.PushPayload) (SlackPayload, error) {
 	// n new commits
diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go
index 6fbf995801..485e2d990b 100644
--- a/services/webhook/telegram.go
+++ b/services/webhook/telegram.go
@@ -174,6 +174,12 @@ func (t telegramConvertor) Package(p *api.PackagePayload) (TelegramPayload, erro
 	return createTelegramPayloadHTML(text), nil
 }
 
+func (t telegramConvertor) Status(p *api.CommitStatusPayload) (TelegramPayload, error) {
+	text, _ := getStatusPayloadInfo(p, htmlLinkFormatter, true)
+
+	return createTelegramPayloadHTML(text), nil
+}
+
 func createTelegramPayloadHTML(msgHTML string) TelegramPayload {
 	// https://core.telegram.org/bots/api#formatting-options
 	return TelegramPayload{
diff --git a/services/webhook/wechatwork.go b/services/webhook/wechatwork.go
index 44e0ff7de5..1c834b4020 100644
--- a/services/webhook/wechatwork.go
+++ b/services/webhook/wechatwork.go
@@ -175,6 +175,12 @@ func (wc wechatworkConvertor) Package(p *api.PackagePayload) (WechatworkPayload,
 	return newWechatworkMarkdownPayload(text), nil
 }
 
+func (wc wechatworkConvertor) Status(p *api.CommitStatusPayload) (WechatworkPayload, error) {
+	text, _ := getStatusPayloadInfo(p, noneLinkFormatter, true)
+
+	return newWechatworkMarkdownPayload(text), nil
+}
+
 func newWechatworkRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
 	var pc payloadConvertor[WechatworkPayload] = wechatworkConvertor{}
 	return newJSONRequest(pc, w, t, true)
diff --git a/templates/admin/auth/list.tmpl b/templates/admin/auth/list.tmpl
index 7931014b1a..a1e72b742f 100644
--- a/templates/admin/auth/list.tmpl
+++ b/templates/admin/auth/list.tmpl
@@ -30,6 +30,8 @@
 							<td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
 							<td><a href="{{AppSubUrl}}/-/admin/auths/{{.ID}}">{{svg "octicon-pencil"}}</a></td>
 						</tr>
+					{{else}}
+						<tr><td class="tw-text-center" colspan="7">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
 					{{end}}
 				</tbody>
 			</table>
diff --git a/templates/admin/emails/list.tmpl b/templates/admin/emails/list.tmpl
index 0dc1fb9d03..b4335aeeec 100644
--- a/templates/admin/emails/list.tmpl
+++ b/templates/admin/emails/list.tmpl
@@ -67,6 +67,8 @@
 								>{{svg "octicon-trash"}}</a>
 							</td>
 						</tr>
+					{{else}}
+						<tr><td class="tw-text-center" colspan="6">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
 					{{end}}
 				</tbody>
 			</table>
diff --git a/templates/admin/notice.tmpl b/templates/admin/notice.tmpl
index fd475d7157..a4c9dc53fb 100644
--- a/templates/admin/notice.tmpl
+++ b/templates/admin/notice.tmpl
@@ -24,6 +24,8 @@
 						<td nowrap>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
 						<td class="view-detail"><a href="#">{{svg "octicon-note" 16}}</a></td>
 					</tr>
+				{{else}}
+					<tr><td class="tw-text-center" colspan="6">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
 				{{end}}
 			</tbody>
 			{{if .Notices}}
diff --git a/templates/admin/org/list.tmpl b/templates/admin/org/list.tmpl
index d5e09939c5..137c42b45d 100644
--- a/templates/admin/org/list.tmpl
+++ b/templates/admin/org/list.tmpl
@@ -66,6 +66,8 @@
 							<td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
 							<td><a href="{{.OrganisationLink}}/settings" data-tooltip-content="{{ctx.Locale.Tr "edit"}}">{{svg "octicon-pencil"}}</a></td>
 						</tr>
+					{{else}}
+						<tr><td class="tw-text-center" colspan="7">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
 					{{end}}
 				</tbody>
 			</table>
diff --git a/templates/admin/packages/list.tmpl b/templates/admin/packages/list.tmpl
index 08c11442bc..0c6889b599 100644
--- a/templates/admin/packages/list.tmpl
+++ b/templates/admin/packages/list.tmpl
@@ -74,6 +74,8 @@
 							<td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td>
 							<td><a class="delete-button" href="" data-url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}" data-id="{{.Version.ID}}" data-name="{{.Package.Name}}" data-data-version="{{.Version.Version}}">{{svg "octicon-trash"}}</a></td>
 						</tr>
+					{{else}}
+						<tr><td class="tw-text-center" colspan="10">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
 					{{end}}
 				</tbody>
 			</table>
diff --git a/templates/admin/repo/list.tmpl b/templates/admin/repo/list.tmpl
index 08fd893e76..762013af47 100644
--- a/templates/admin/repo/list.tmpl
+++ b/templates/admin/repo/list.tmpl
@@ -86,6 +86,8 @@
 							<td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
 							<td><a class="delete-button" href="" data-url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}" data-id="{{.ID}}" data-name="{{.Name}}">{{svg "octicon-trash"}}</a></td>
 						</tr>
+					{{else}}
+						<tr><td class="tw-text-center" colspan="12">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
 					{{end}}
 				</tbody>
 			</table>
diff --git a/templates/admin/user/edit.tmpl b/templates/admin/user/edit.tmpl
index d591a645d8..c04d332660 100644
--- a/templates/admin/user/edit.tmpl
+++ b/templates/admin/user/edit.tmpl
@@ -195,8 +195,7 @@
 				</div>
 
 				<div class="inline field tw-pl-4">
-					<label for="avatar">{{ctx.Locale.Tr "settings.choose_new_avatar"}}</label>
-					<input name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
+					{{template "shared/avatar_upload_crop" dict "LabelText" (ctx.Locale.Tr "settings.choose_new_avatar")}}
 				</div>
 
 				<div class="field">
diff --git a/templates/admin/user/list.tmpl b/templates/admin/user/list.tmpl
index 7e4c8854f5..eb3f6cd720 100644
--- a/templates/admin/user/list.tmpl
+++ b/templates/admin/user/list.tmpl
@@ -109,6 +109,8 @@
 								</div>
 							</td>
 						</tr>
+					{{else}}
+						<tr class="no-results-row"><td class="tw-text-center" colspan="9">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
 					{{end}}
 				</tbody>
 			</table>
diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl
index 4a8aee68a7..2d3af2d559 100644
--- a/templates/org/menu.tmpl
+++ b/templates/org/menu.tmpl
@@ -45,6 +45,11 @@
 			</a>
 			{{end}}
 			{{if .IsOrganizationOwner}}
+			<a class="{{if $.PageIsOrgTimes}}active{{end}} item" href="{{$.OrgLink}}/worktime">
+				{{svg "octicon-clock"}} {{ctx.Locale.Tr "org.worktime"}}
+			</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"}}
diff --git a/templates/org/settings/options.tmpl b/templates/org/settings/options.tmpl
index 3b817d068b..76315f3eac 100644
--- a/templates/org/settings/options.tmpl
+++ b/templates/org/settings/options.tmpl
@@ -89,10 +89,8 @@
 					<form class="ui form" action="{{.Link}}/avatar" method="post" enctype="multipart/form-data">
 						{{.CsrfTokenHtml}}
 						<div class="inline field">
-							<label for="avatar">{{ctx.Locale.Tr "settings.choose_new_avatar"}}</label>
-							<input name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
+							{{template "shared/avatar_upload_crop" dict "LabelText" (ctx.Locale.Tr "settings.choose_new_avatar")}}
 						</div>
-
 						<div class="field">
 							<button class="ui primary button">{{ctx.Locale.Tr "settings.update_avatar"}}</button>
 							<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{ctx.Locale.Tr "settings.delete_current_avatar"}}</button>
diff --git a/templates/org/worktime.tmpl b/templates/org/worktime.tmpl
new file mode 100644
index 0000000000..5d99998129
--- /dev/null
+++ b/templates/org/worktime.tmpl
@@ -0,0 +1,40 @@
+{{template "base/head" .}}
+<div class="page-content organization times">
+	{{template "org/header" .}}
+	<div class="ui container">
+		<div class="ui grid">
+			<div class="three wide column">
+				<form class="ui form" method="get">
+					<input type="hidden" name="by" value="{{$.WorktimeBy}}">
+					<div class="field">
+						<label>{{ctx.Locale.Tr "org.worktime.date_range_start"}}</label>
+						<input type="date" name="from" value="{{.RangeFrom}}">
+					</div>
+					<div class="field">
+						<label>{{ctx.Locale.Tr "org.worktime.date_range_end"}}</label>
+						<input type="date" name="to" value="{{.RangeTo}}">
+					</div>
+					<button class="ui primary button">{{ctx.Locale.Tr "org.worktime.query"}}</button>
+				</form>
+			</div>
+			<div class="thirteen wide column">
+				<div class="ui column">
+					<div class="ui compact small menu">
+						{{$queryParams := QueryBuild "from" .RangeFrom "to" .RangeTo}}
+						<a class="{{Iif .WorktimeByRepos "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=repos&{{$queryParams}}">{{svg "octicon-repo"}} {{ctx.Locale.Tr "org.worktime.by_repositories"}}</a>
+						<a class="{{Iif .WorktimeByMilestones "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=milestones&{{$queryParams}}">{{svg "octicon-milestone"}} {{ctx.Locale.Tr "org.worktime.by_milestones"}}</a>
+						<a class="{{Iif .WorktimeByMembers "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=members&{{$queryParams}}">{{svg "octicon-people"}} {{ctx.Locale.Tr "org.worktime.by_members"}}</a>
+					</div>
+				</div>
+				{{if .WorktimeByRepos}}
+					{{template "org/worktime/table_repos" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}}
+				{{else if .WorktimeByMilestones}}
+					{{template "org/worktime/table_milestones" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}}
+				{{else if .WorktimeByMembers}}
+					{{template "org/worktime/table_members" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}}
+				{{end}}
+			</div>
+		</div>
+	</div>
+</div>
+{{template "base/footer" .}}
diff --git a/templates/org/worktime/table_members.tmpl b/templates/org/worktime/table_members.tmpl
new file mode 100644
index 0000000000..a59d1941d8
--- /dev/null
+++ b/templates/org/worktime/table_members.tmpl
@@ -0,0 +1,16 @@
+<table class="ui table">
+	<thead>
+		<tr>
+			<th>{{ctx.Locale.Tr "org.members.member"}}</th>
+			<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
+		</tr>
+	</thead>
+	<tbody>
+		{{range $.WorktimeSumResult}}
+		<tr>
+			<td>{{svg "octicon-person"}} <a href="{{AppSubUrl}}/{{PathEscape .UserName}}">{{.UserName}}</a></td>
+			<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
+		</tr>
+		{{end}}
+	</tbody>
+</table>
diff --git a/templates/org/worktime/table_milestones.tmpl b/templates/org/worktime/table_milestones.tmpl
new file mode 100644
index 0000000000..6ef9289e56
--- /dev/null
+++ b/templates/org/worktime/table_milestones.tmpl
@@ -0,0 +1,28 @@
+<table class="ui table">
+	<thead>
+		<tr>
+			<th>{{ctx.Locale.Tr "repository"}}</th>
+			<th>{{ctx.Locale.Tr "repo.milestone"}}</th>
+			<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
+		</tr>
+	</thead>
+	<tbody>
+		{{range $.WorktimeSumResult}}
+		<tr>
+			<td>
+				{{if not .HideRepoName}}
+					{{svg "octicon-repo"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/issues">{{.RepoName}}</a>
+				{{end}}
+			</td>
+			<td>
+				{{if .MilestoneName}}
+					{{svg "octicon-milestone"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/milestone/{{.MilestoneID}}">{{.MilestoneName}}</a>
+				{{else}}
+					-
+				{{end}}
+			</td>
+			<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
+		</tr>
+		{{end}}
+	</tbody>
+</table>
diff --git a/templates/org/worktime/table_repos.tmpl b/templates/org/worktime/table_repos.tmpl
new file mode 100644
index 0000000000..eaa085df0c
--- /dev/null
+++ b/templates/org/worktime/table_repos.tmpl
@@ -0,0 +1,16 @@
+<table class="ui table">
+	<thead>
+		<tr>
+			<th>{{ctx.Locale.Tr "repository"}}</th>
+			<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
+		</tr>
+	</thead>
+	<tbody>
+		{{range $.WorktimeSumResult}}
+		<tr>
+			<td>{{svg "octicon-repo"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/issues">{{.RepoName}}</a></td>
+			<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
+		</tr>
+		{{end}}
+	</tbody>
+</table>
diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl
index f5a48f7241..7c75585bf7 100644
--- a/templates/projects/list.tmpl
+++ b/templates/projects/list.tmpl
@@ -34,6 +34,8 @@
 			{{svg "octicon-triangle-down" 14 "dropdown icon"}}
 			<div class="menu">
 				<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="?q={{$.Keyword}}&sort=oldest&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</a>
+				<a class="{{if eq .SortType "alphabetically"}}active {{end}}item" href="?q={{$.Keyword}}&sort=alphabetically&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.label.filter_sort.alphabetically"}}</a>
+				<a class="{{if eq .SortType "reversealphabetically"}}active {{end}}item" href="?q={{$.Keyword}}&sort=reversealphabetically&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.label.filter_sort.reverse_alphabetically"}}</a>
 				<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="?q={{$.Keyword}}&sort=recentupdate&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.recentupdate"}}</a>
 				<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="?q={{$.Keyword}}&sort=leastupdate&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastupdate"}}</a>
 			</div>
diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl
index ea01d96928..a3b64b8a11 100644
--- a/templates/repo/diff/box.tmpl
+++ b/templates/repo/diff/box.tmpl
@@ -24,7 +24,7 @@
 			{{end}}
 		</div>
 		<div class="diff-detail-actions">
-			{{if and .PageIsPullFiles $.SignedUserID (not .IsArchived) (not .DiffNotAvailable)}}
+			{{if and .PageIsPullFiles $.SignedUserID (not .DiffNotAvailable)}}
 				<div class="not-mobile tw-flex tw-items-center tw-flex-col tw-whitespace-nowrap tw-mr-1">
 					<label for="viewed-files-summary" id="viewed-files-summary-label" data-text-changed-template="{{ctx.Locale.Tr "repo.pulls.viewed_files_label"}}">
 						{{ctx.Locale.Tr "repo.pulls.viewed_files_label" .Diff.NumViewedFiles .Diff.NumFiles}}
@@ -42,7 +42,7 @@
 					</div>
 				</div>
 			{{end}}
-			{{if and .PageIsPullFiles $.SignedUserID (not .IsArchived)}}
+			{{if and .PageIsPullFiles $.SignedUserID}}
 				{{template "repo/diff/new_review" .}}
 			{{end}}
 		</div>
@@ -105,7 +105,7 @@
 					{{$isCsv := (call $.IsCsvFile $file)}}
 					{{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}}
 					{{$isExpandable := or (gt $file.Addition 0) (gt $file.Deletion 0) $file.IsBin}}
-					{{$isReviewFile := and $.IsSigned $.PageIsPullFiles (not $.IsArchived) $.IsShowingAllCommits}}
+					{{$isReviewFile := and $.IsSigned $.PageIsPullFiles (not $.Repository.IsArchived) $.IsShowingAllCommits}}
 					<div class="diff-file-box diff-box file-content {{TabSizeClass $.Editorconfig $file.Name}} tw-mt-0" id="diff-{{$file.NameHash}}" data-old-filename="{{$file.OldName}}" data-new-filename="{{$file.Name}}" {{if or ($file.ShouldBeHidden) (not $isExpandable)}}data-folded="true"{{end}}>
 						<h4 class="diff-file-header sticky-2nd-row ui top attached header">
 							<div class="diff-file-name tw-flex tw-flex-1 tw-items-center tw-gap-1 tw-flex-wrap">
diff --git a/templates/repo/diff/comments.tmpl b/templates/repo/diff/comments.tmpl
index ec52934a9d..2e8261e479 100644
--- a/templates/repo/diff/comments.tmpl
+++ b/templates/repo/diff/comments.tmpl
@@ -48,7 +48,9 @@
 						</div>
 					{{end}}
 				{{end}}
-				{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID)}}
+				{{if not $.root.Repository.IsArchived}}
+					{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID)}}
+				{{end}}
 				{{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" false "diff" true "IsCommentPoster" (and $.root.IsSigned (eq $.root.SignedUserID .PosterID))}}
 			</div>
 		</div>
diff --git a/templates/repo/diff/new_review.tmpl b/templates/repo/diff/new_review.tmpl
index 2febc6303a..3bb01a139a 100644
--- a/templates/repo/diff/new_review.tmpl
+++ b/templates/repo/diff/new_review.tmpl
@@ -1,56 +1,59 @@
-<div id="review-box">
-	<button class="ui tiny primary button tw-pr-1 tw-flex js-btn-review {{if not $.IsShowingAllCommits}}disabled{{end}}" {{if not $.IsShowingAllCommits}}data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.review_only_possible_for_full_diff"}}"{{end}}>
+<div id="review-box" {{if $.Repository.IsArchived}}data-tooltip-content="{{ctx.Locale.Tr "repo.archive.pull.nocomment"}}"{{end}}>
+	<button class="ui tiny primary button tw-pr-1 js-btn-review {{if not $.IsShowingAllCommits}}disabled{{end}}"
+		{{if not $.IsShowingAllCommits}}data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.review_only_possible_for_full_diff"}}"{{end}}
+		{{if $.Repository.IsArchived}}disabled{{end}}
+	>
 		{{ctx.Locale.Tr "repo.diff.review"}}
 		<span class="ui small label review-comments-counter" data-pending-comment-number="{{.PendingCodeCommentNumber}}">{{.PendingCodeCommentNumber}}</span>
 		{{svg "octicon-triangle-down" 14 "dropdown icon"}}
 	</button>
-	{{if $.IsShowingAllCommits}}
-	<div class="review-box-panel tippy-target">
-		<div class="ui segment">
-			<form class="ui form form-fetch-action" action="{{.Link}}/reviews/submit" method="post">
-				{{.CsrfTokenHtml}}
-				<input type="hidden" name="commit_id" value="{{.AfterCommitID}}">
-				<div class="field tw-flex tw-items-center">
-					<div class="tw-flex-1">{{ctx.Locale.Tr "repo.diff.review.header"}}</div>
-					<a class="muted close">{{svg "octicon-x" 16}}</a>
-				</div>
-				<div class="field">
-					{{template "shared/combomarkdowneditor" (dict
-						"MarkdownPreviewInRepo" $.Repository
-						"MarkdownPreviewMode" "comment"
-						"TextareaName" "content"
-						"TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.review.placeholder")
-						"DropzoneParentContainer" "form"
-					)}}
-				</div>
-				{{if .IsAttachmentEnabled}}
-					<div class="field">
-						{{template "repo/upload" .}}
-					</div>
-				{{end}}
-				<div class="divider"></div>
-				{{$showSelfTooltip := (and $.IsSigned ($.Issue.IsPoster $.SignedUser.ID))}}
-				{{if not $.Issue.IsClosed}}
-					{{if $showSelfTooltip}}
-						<span class="tw-inline-block" data-tooltip-content="{{ctx.Locale.Tr "repo.diff.review.self_approve"}}">
-							<button type="submit" name="type" value="approve" disabled class="ui submit primary tiny button btn-submit">{{ctx.Locale.Tr "repo.diff.review.approve"}}</button>
-						</span>
-					{{else}}
-						<button type="submit" name="type" value="approve" class="ui submit primary tiny button btn-submit">{{ctx.Locale.Tr "repo.diff.review.approve"}}</button>
-					{{end}}
-				{{end}}
-				<button type="submit" name="type" value="comment" class="ui submit tiny basic button btn-submit">{{ctx.Locale.Tr "repo.diff.review.comment"}}</button>
-				{{if not $.Issue.IsClosed}}
-					{{if $showSelfTooltip}}
-						<span class="tw-inline-block" data-tooltip-content="{{ctx.Locale.Tr "repo.diff.review.self_reject"}}">
-							<button type="submit" name="type" value="reject" disabled class="ui submit red tiny button btn-submit">{{ctx.Locale.Tr "repo.diff.review.reject"}}</button>
-						</span>
-					{{else}}
-						<button type="submit" name="type" value="reject" class="ui submit red tiny button btn-submit">{{ctx.Locale.Tr "repo.diff.review.reject"}}</button>
-					{{end}}
-				{{end}}
-			</form>
-		</div>
-	</div>
-	{{end}}
 </div>
+{{if $.IsShowingAllCommits}}
+<div class="review-box-panel tippy-target">
+	<div class="ui segment">
+		<form class="ui form form-fetch-action" action="{{.Link}}/reviews/submit" method="post">
+			{{.CsrfTokenHtml}}
+			<input type="hidden" name="commit_id" value="{{.AfterCommitID}}">
+			<div class="field tw-flex tw-items-center">
+				<div class="tw-flex-1">{{ctx.Locale.Tr "repo.diff.review.header"}}</div>
+				<a class="muted close">{{svg "octicon-x" 16}}</a>
+			</div>
+			<div class="field">
+				{{template "shared/combomarkdowneditor" (dict
+					"MarkdownPreviewInRepo" $.Repository
+					"MarkdownPreviewMode" "comment"
+					"TextareaName" "content"
+					"TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.review.placeholder")
+					"DropzoneParentContainer" "form"
+				)}}
+			</div>
+			{{if .IsAttachmentEnabled}}
+				<div class="field">
+					{{template "repo/upload" .}}
+				</div>
+			{{end}}
+			<div class="divider"></div>
+			{{$showSelfTooltip := (and $.IsSigned ($.Issue.IsPoster $.SignedUser.ID))}}
+			{{if not $.Issue.IsClosed}}
+				{{if $showSelfTooltip}}
+					<span class="tw-inline-block" data-tooltip-content="{{ctx.Locale.Tr "repo.diff.review.self_approve"}}">
+						<button type="submit" name="type" value="approve" disabled class="ui submit primary tiny button btn-submit">{{ctx.Locale.Tr "repo.diff.review.approve"}}</button>
+					</span>
+				{{else}}
+					<button type="submit" name="type" value="approve" class="ui submit primary tiny button btn-submit">{{ctx.Locale.Tr "repo.diff.review.approve"}}</button>
+				{{end}}
+			{{end}}
+			<button type="submit" name="type" value="comment" class="ui submit tiny basic button btn-submit">{{ctx.Locale.Tr "repo.diff.review.comment"}}</button>
+			{{if not $.Issue.IsClosed}}
+				{{if $showSelfTooltip}}
+					<span class="tw-inline-block" data-tooltip-content="{{ctx.Locale.Tr "repo.diff.review.self_reject"}}">
+						<button type="submit" name="type" value="reject" disabled class="ui submit red tiny button btn-submit">{{ctx.Locale.Tr "repo.diff.review.reject"}}</button>
+					</span>
+				{{else}}
+					<button type="submit" name="type" value="reject" class="ui submit red tiny button btn-submit">{{ctx.Locale.Tr "repo.diff.review.reject"}}</button>
+				{{end}}
+			{{end}}
+		</form>
+	</div>
+</div>
+{{end}}
diff --git a/templates/repo/issue/filters.tmpl b/templates/repo/issue/filters.tmpl
index 06e7c1aa6c..409ec876e6 100644
--- a/templates/repo/issue/filters.tmpl
+++ b/templates/repo/issue/filters.tmpl
@@ -9,7 +9,7 @@
 			<div class="ui compact tiny secondary menu">
 				<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
 					{{svg "octicon-clock"}}
-					{{.TotalTrackedTime | Sec2Time}}
+					{{.TotalTrackedTime | Sec2Hour}}
 				</span>
 			</div>
 		{{end}}
diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl
index 01b610b39d..53d0eca171 100644
--- a/templates/repo/issue/list.tmpl
+++ b/templates/repo/issue/list.tmpl
@@ -40,7 +40,7 @@
 					<div class="ui compact tiny secondary menu">
 						<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
 							{{svg "octicon-clock"}}
-							{{.TotalTrackedTime | Sec2Time}}
+							{{.TotalTrackedTime | Sec2Hour}}
 						</span>
 					</div>
 				{{end}}
diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl
index 4fc6057117..abb4e3290d 100644
--- a/templates/repo/issue/milestone_issues.tmpl
+++ b/templates/repo/issue/milestone_issues.tmpl
@@ -50,7 +50,7 @@
 				{{if .TotalTrackedTime}}
 					<div data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
 						{{svg "octicon-clock"}}
-						{{.TotalTrackedTime | Sec2Time}}
+						{{.TotalTrackedTime | Sec2Hour}}
 					</div>
 				{{end}}
 			</div>
diff --git a/templates/repo/issue/milestones.tmpl b/templates/repo/issue/milestones.tmpl
index 9515acfb8e..e7dfe08ee0 100644
--- a/templates/repo/issue/milestones.tmpl
+++ b/templates/repo/issue/milestones.tmpl
@@ -41,7 +41,7 @@
 							{{if .TotalTrackedTime}}
 								<div class="flex-text-block">
 									{{svg "octicon-clock"}}
-									{{.TotalTrackedTime|Sec2Time}}
+									{{.TotalTrackedTime|Sec2Hour}}
 								</div>
 							{{end}}
 							{{if .UpdatedUnix}}
diff --git a/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl b/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl
index f107dc5ef5..d5ac6827ba 100644
--- a/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl
+++ b/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl
@@ -72,7 +72,7 @@
 	{{end}}
 	{{if .WorkingUsers}}
 		<div class="ui comments tw-mt-2">
-			{{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Time)}}
+			{{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Hour)}}
 			<div>
 				{{range $user, $trackedtime := .WorkingUsers}}
 					<div class="comment tw-mt-2">
@@ -82,7 +82,7 @@
 						<div class="content">
 							{{template "shared/user/authorlink" $user}}
 							<div class="text">
-								{{$trackedtime|Sec2Time}}
+								{{$trackedtime|Sec2Hour}}
 							</div>
 						</div>
 					</div>
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl
index c1ad64a118..f2f3d1c9cc 100644
--- a/templates/repo/issue/view_content/comments.tmpl
+++ b/templates/repo/issue/view_content/comments.tmpl
@@ -252,7 +252,7 @@
 				<span class="text grey muted-links">
 					{{template "shared/user/authorlink" .Poster}}
 					{{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}}
-					{{if not $timeStr}}{{$timeStr = .Content|Sec2Time}}{{end}}
+					{{if not $timeStr}}{{$timeStr = .Content|Sec2Hour}}{{end}}
 					{{ctx.Locale.Tr "repo.issues.stop_tracking_history" $timeStr $createdStr}}
 				</span>
 				{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
@@ -264,7 +264,7 @@
 				<span class="text grey muted-links">
 					{{template "shared/user/authorlink" .Poster}}
 					{{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}}
-					{{if not $timeStr}}{{$timeStr = .Content|Sec2Time}}{{end}}
+					{{if not $timeStr}}{{$timeStr = .Content|Sec2Hour}}{{end}}
 					{{ctx.Locale.Tr "repo.issues.add_time_history" $timeStr $createdStr}}
 				</span>
 				{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
@@ -506,7 +506,7 @@
 						{{/* compatibility with time comments made before v1.21 */}}
 						<span class="text grey muted-links">{{.RenderedContent}}</span>
 					{{else}}
-						<span class="text grey muted-links">- {{.Content|Sec2Time}}</span>
+						<span class="text grey muted-links">- {{.Content|Sec2Hour}}</span>
 					{{end}}
 				</div>
 			</div>
diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl
index cb596f013b..0520c87cc1 100644
--- a/templates/repo/settings/options.tmpl
+++ b/templates/repo/settings/options.tmpl
@@ -40,8 +40,7 @@
 			<form class="ui form" action="{{.Link}}/avatar" method="post" enctype="multipart/form-data">
 				{{.CsrfTokenHtml}}
 				<div class="inline field">
-					<label for="avatar">{{ctx.Locale.Tr "settings.choose_new_avatar"}}</label>
-					<input name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
+					{{template "shared/avatar_upload_crop" dict "LabelText" (ctx.Locale.Tr "settings.choose_new_avatar")}}
 				</div>
 				<div class="field">
 					<button class="ui primary button">{{ctx.Locale.Tr "settings.update_avatar"}}</button>
diff --git a/templates/repo/settings/webhook/settings.tmpl b/templates/repo/settings/webhook/settings.tmpl
index 1a01a6aea8..3b28a4c6c0 100644
--- a/templates/repo/settings/webhook/settings.tmpl
+++ b/templates/repo/settings/webhook/settings.tmpl
@@ -109,6 +109,17 @@
 			</div>
 		</div>
 
+		<!-- Status -->
+		<div class="seven wide column">
+			<div class="field">
+				<div class="ui checkbox">
+					<input name="status" type="checkbox" {{if .Webhook.HookEvents.Get "status"}}checked{{end}}>
+					<label>{{ctx.Locale.Tr "repo.settings.event_statuses"}}</label>
+					<span class="help">{{ctx.Locale.Tr "repo.settings.event_statuses_desc"}}</span>
+				</div>
+			</div>
+		</div>
+
 		<!-- Issue Events -->
 		<div class="fourteen wide column">
 			<label>{{ctx.Locale.Tr "repo.settings.event_header_issue"}}</label>
diff --git a/templates/shared/avatar_upload_crop.tmpl b/templates/shared/avatar_upload_crop.tmpl
new file mode 100644
index 0000000000..2c4166fa9c
--- /dev/null
+++ b/templates/shared/avatar_upload_crop.tmpl
@@ -0,0 +1,8 @@
+{{- /* we do not need to set for/id here, global aria init code will add them automatically */ -}}
+<label>{{.LabelText}}</label>
+<input class="avatar-file-with-cropper" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
+{{- /* the cropper-panel must be next sibling of the input "avatar" */ -}}
+<div class="cropper-panel tw-hidden">
+	<div class="tw-my-2">{{ctx.Locale.Tr "settings.cropper_prompt"}}</div>
+	<div class="cropper-wrapper"><img class="cropper-source" src alt></div>
+</div>
diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl
index e8015b40ea..fe7f2fd8bf 100644
--- a/templates/shared/issuelist.tmpl
+++ b/templates/shared/issuelist.tmpl
@@ -28,7 +28,7 @@
 					{{if .TotalTrackedTime}}
 					<div class="text grey flex-text-block">
 							{{svg "octicon-clock" 16}}
-							{{.TotalTrackedTime | Sec2Time}}
+							{{.TotalTrackedTime | Sec2Hour}}
 					</div>
 					{{end}}
 				</div>
diff --git a/templates/shared/user/authorlink.tmpl b/templates/shared/user/authorlink.tmpl
index d57a635b4b..abfee6aae3 100644
--- a/templates/shared/user/authorlink.tmpl
+++ b/templates/shared/user/authorlink.tmpl
@@ -1 +1 @@
-<a class="author text black tw-font-semibold muted"{{if gt .ID 0}} href="{{.HomeLink}}"{{end}}>{{.GetDisplayName}}</a>{{if .IsBot}}<span class="ui basic label tw-p-1">bot</span>{{end}}
+<a class="author text black tw-font-semibold muted"{{if gt .ID 0}} href="{{.HomeLink}}"{{end}}>{{.GetDisplayName}}</a>{{if .IsTypeBot}}<span class="ui basic label tw-p-1 tw-align-baseline">bot</span>{{end}}
diff --git a/templates/shared/webhook/icon.tmpl b/templates/shared/webhook/icon.tmpl
index 0f80787c57..245ed16505 100644
--- a/templates/shared/webhook/icon.tmpl
+++ b/templates/shared/webhook/icon.tmpl
@@ -17,7 +17,7 @@
 {{else if eq .HookType "msteams"}}
 	<img width="{{$size}}" height="{{$size}}" src="{{AssetUrlPrefix}}/img/msteams.png">
 {{else if eq .HookType "feishu"}}
-	<img width="{{$size}}" height="{{$size}}" src="{{AssetUrlPrefix}}/img/feishu.png">
+	{{svg "gitea-feishu" $size "img"}}
 {{else if eq .HookType "matrix"}}
 	{{svg "gitea-matrix" $size "img"}}
 {{else if eq .HookType "wechatwork"}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 8082fc594a..80cf1b5623 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -2991,6 +2991,46 @@
         }
       }
     },
+    "/orgs/{org}/rename": {
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Rename an organization",
+        "operationId": "renameOrg",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "existing org name",
+            "name": "org",
+            "in": "path",
+            "required": true
+          },
+          {
+            "name": "body",
+            "in": "body",
+            "required": true,
+            "schema": {
+              "$ref": "#/definitions/RenameOrgOption"
+            }
+          }
+        ],
+        "responses": {
+          "204": {
+            "$ref": "#/responses/empty"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
     "/orgs/{org}/repos": {
       "get": {
         "produces": [
@@ -4381,6 +4421,275 @@
         }
       }
     },
+    "/repos/{owner}/{repo}/actions/workflows": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "List repository workflows",
+        "operationId": "ActionsListRepositoryWorkflows",
+        "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
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/ActionWorkflowList"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          },
+          "500": {
+            "$ref": "#/responses/error"
+          }
+        }
+      }
+    },
+    "/repos/{owner}/{repo}/actions/workflows/{workflow_id}": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Get a workflow",
+        "operationId": "ActionsGetWorkflow",
+        "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": "string",
+            "description": "id of the workflow",
+            "name": "workflow_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/ActionWorkflow"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          },
+          "500": {
+            "$ref": "#/responses/error"
+          }
+        }
+      }
+    },
+    "/repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Disable a workflow",
+        "operationId": "ActionsDisableWorkflow",
+        "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": "string",
+            "description": "id of the workflow",
+            "name": "workflow_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "No Content"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
+    "/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches": {
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Create a workflow dispatch event",
+        "operationId": "ActionsDispatchWorkflow",
+        "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": "string",
+            "description": "id of the workflow",
+            "name": "workflow_id",
+            "in": "path",
+            "required": true
+          },
+          {
+            "name": "body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/CreateActionWorkflowDispatch"
+            }
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "No Content"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
+    "/repos/{owner}/{repo}/actions/workflows/{workflow_id}/enable": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Enable a workflow",
+        "operationId": "ActionsEnableWorkflow",
+        "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": "string",
+            "description": "id of the workflow",
+            "name": "workflow_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "No Content"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "409": {
+            "$ref": "#/responses/conflict"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/activities/feeds": {
       "get": {
         "produces": [
@@ -13768,6 +14077,9 @@
           "200": {
             "$ref": "#/responses/UserList"
           },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
           "404": {
             "$ref": "#/responses/notFound"
           }
@@ -17506,6 +17818,9 @@
         "responses": {
           "200": {
             "$ref": "#/responses/RepositoryList"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
           }
         }
       }
@@ -17537,6 +17852,9 @@
           "204": {
             "$ref": "#/responses/empty"
           },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
           "404": {
             "$ref": "#/responses/notFound"
           }
@@ -17602,6 +17920,9 @@
           "204": {
             "$ref": "#/responses/empty"
           },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
           "404": {
             "$ref": "#/responses/notFound"
           }
@@ -18278,6 +18599,9 @@
           "200": {
             "$ref": "#/responses/RepositoryList"
           },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
           "404": {
             "$ref": "#/responses/notFound"
           }
@@ -18625,6 +18949,56 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "ActionWorkflow": {
+      "description": "ActionWorkflow represents a ActionWorkflow",
+      "type": "object",
+      "properties": {
+        "badge_url": {
+          "type": "string",
+          "x-go-name": "BadgeURL"
+        },
+        "created_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "CreatedAt"
+        },
+        "deleted_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "DeletedAt"
+        },
+        "html_url": {
+          "type": "string",
+          "x-go-name": "HTMLURL"
+        },
+        "id": {
+          "type": "string",
+          "x-go-name": "ID"
+        },
+        "name": {
+          "type": "string",
+          "x-go-name": "Name"
+        },
+        "path": {
+          "type": "string",
+          "x-go-name": "Path"
+        },
+        "state": {
+          "type": "string",
+          "x-go-name": "State"
+        },
+        "updated_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "UpdatedAt"
+        },
+        "url": {
+          "type": "string",
+          "x-go-name": "URL"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "Activity": {
       "type": "object",
       "properties": {
@@ -19633,6 +20007,28 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "CreateActionWorkflowDispatch": {
+      "description": "CreateActionWorkflowDispatch represents the payload for triggering a workflow dispatch event",
+      "type": "object",
+      "required": [
+        "ref"
+      ],
+      "properties": {
+        "inputs": {
+          "type": "object",
+          "additionalProperties": {
+            "type": "string"
+          },
+          "x-go-name": "Inputs"
+        },
+        "ref": {
+          "type": "string",
+          "x-go-name": "Ref",
+          "example": "refs/heads/main"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "CreateBranchProtectionOption": {
       "description": "CreateBranchProtectionOption options for creating a branch protection",
       "type": "object",
@@ -24207,6 +24603,22 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "RenameOrgOption": {
+      "description": "RenameOrgOption options when renaming an organization",
+      "type": "object",
+      "required": [
+        "new_name"
+      ],
+      "properties": {
+        "new_name": {
+          "description": "New username for this org. This name cannot be in use yet by any other user.",
+          "type": "string",
+          "uniqueItems": true,
+          "x-go-name": "NewName"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "RenameUserOption": {
       "description": "RenameUserOption options when renaming a user",
       "type": "object",
@@ -25616,6 +26028,21 @@
         "$ref": "#/definitions/ActionVariable"
       }
     },
+    "ActionWorkflow": {
+      "description": "ActionWorkflow",
+      "schema": {
+        "$ref": "#/definitions/ActionWorkflow"
+      }
+    },
+    "ActionWorkflowList": {
+      "description": "ActionWorkflowList",
+      "schema": {
+        "type": "array",
+        "items": {
+          "$ref": "#/definitions/ActionWorkflow"
+        }
+      }
+    },
     "ActivityFeedsList": {
       "description": "ActivityFeedsList",
       "schema": {
diff --git a/templates/user/dashboard/dashboard.tmpl b/templates/user/dashboard/dashboard.tmpl
index 5dc46dc0a5..3ce3c1eb73 100644
--- a/templates/user/dashboard/dashboard.tmpl
+++ b/templates/user/dashboard/dashboard.tmpl
@@ -5,7 +5,11 @@
 		<div class="flex-container-main">
 			{{template "base/alert" .}}
 			{{template "user/heatmap" .}}
-			{{template "user/dashboard/feeds" .}}
+			{{if .Feeds}}
+				{{template "user/dashboard/feeds" .}}
+			{{else}}
+				{{template "user/dashboard/guide" .}}
+			{{end}}
 		</div>
 		{{template "user/dashboard/repolist" .}}
 	</div>
diff --git a/templates/user/dashboard/guide.tmpl b/templates/user/dashboard/guide.tmpl
new file mode 100644
index 0000000000..bdbe81ece0
--- /dev/null
+++ b/templates/user/dashboard/guide.tmpl
@@ -0,0 +1,10 @@
+<div class="tw-text-center tw-p-8">
+	{{svg "octicon-package" 24 "tw-text-placeholder-text"}}
+	<h3 class="tw-my-4">{{ctx.Locale.Tr "home.guide_title"}}</h3>
+	<p class="tw-text-placeholder-text">{{ctx.Locale.Tr "home.guide_desc"}}</p>
+	<div>
+		<a href="{{AppSubUrl}}/explore/repos">{{ctx.Locale.Tr "home.explore_repos"}}</a>
+		<span>·</span>
+		<a href="{{AppSubUrl}}/explore/users">{{ctx.Locale.Tr "home.explore_users"}}</a>
+	</div>
+</div>
diff --git a/templates/user/dashboard/milestones.tmpl b/templates/user/dashboard/milestones.tmpl
index c0059d3cd4..7c1a69a6f5 100644
--- a/templates/user/dashboard/milestones.tmpl
+++ b/templates/user/dashboard/milestones.tmpl
@@ -100,7 +100,7 @@
 									{{if .TotalTrackedTime}}
 										<div class="flex-text-block">
 											{{svg "octicon-clock"}}
-											{{.TotalTrackedTime|Sec2Time}}
+											{{.TotalTrackedTime|Sec2Hour}}
 										</div>
 									{{end}}
 									{{if .UpdatedUnix}}
diff --git a/templates/user/dashboard/repolist.tmpl b/templates/user/dashboard/repolist.tmpl
index a2764ba608..8b0fcbb401 100644
--- a/templates/user/dashboard/repolist.tmpl
+++ b/templates/user/dashboard/repolist.tmpl
@@ -5,6 +5,10 @@ const data = {
 	isMirrorsEnabled: {{.MirrorsEnabled}},
 	isStarsEnabled: {{not .IsDisableStars}},
 
+	canCreateMigrations: {{not .DisableMigrations}},
+
+	textNoOrg: {{ctx.Locale.Tr "home.empty_org"}},
+	textNoRepo: {{ctx.Locale.Tr "home.empty_repo"}},
 	textRepository: {{ctx.Locale.Tr "repository"}},
 	textOrganization: {{ctx.Locale.Tr "organization"}},
 	textMyRepos: {{ctx.Locale.Tr "home.my_repos"}},
diff --git a/templates/user/settings/keys_ssh.tmpl b/templates/user/settings/keys_ssh.tmpl
index b894ccdfbd..9d62d4ab08 100644
--- a/templates/user/settings/keys_ssh.tmpl
+++ b/templates/user/settings/keys_ssh.tmpl
@@ -78,7 +78,16 @@
 							<input readonly="" value="{{$.TokenToSign}}">
 							<div class="help">
 								<p>{{ctx.Locale.Tr "settings.ssh_token_help"}}</p>
-								<p><code>{{printf "echo -n '%s' | ssh-keygen -Y sign -n gitea -f /path_to_PrivateKey_or_RelatedPublicKey" $.TokenToSign}}</code></p>
+								<p><code>echo -n '{{$.TokenToSign}}' | ssh-keygen -Y sign -n gitea -f /path_to_PrivateKey_or_RelatedPublicKey</code></p>
+								<details>
+									<summary>Windows PowerShell</summary>
+									<p><code>cmd /c "&lt;NUL set /p=`"{{$.TokenToSign}}`"| ssh-keygen -Y sign -n gitea -f /path_to_PrivateKey_or_RelatedPublicKey"</code></p>
+								</details>
+								<br>
+								<details>
+									<summary>Windows CMD</summary>
+									<p><code>set /p={{$.TokenToSign}}| ssh-keygen -Y sign -n gitea -f /path_to_PrivateKey_or_RelatedPublicKey</code></p>
+								</details>
 							</div>
 							<br>
 						</div>
diff --git a/templates/user/settings/profile.tmpl b/templates/user/settings/profile.tmpl
index 197763425c..03c3c18f28 100644
--- a/templates/user/settings/profile.tmpl
+++ b/templates/user/settings/profile.tmpl
@@ -124,13 +124,7 @@
 				</div>
 
 				<div class="inline field tw-pl-4">
-					<label for="new-avatar">{{ctx.Locale.Tr "settings.choose_new_avatar"}}</label>
-					<input id="new-avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
-				</div>
-
-				<div class="field tw-pl-4 cropper-panel tw-hidden">
-					<div>{{ctx.Locale.Tr "settings.cropper_prompt"}}</div>
-					<div class="cropper-wrapper"><img class="cropper-source" src alt></div>
+					{{template "shared/avatar_upload_crop" dict "LabelText" (ctx.Locale.Tr "settings.choose_new_avatar")}}
 				</div>
 
 				<div class="field">
diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go
index 8ea9b34efe..096f51dfc0 100644
--- a/tests/integration/actions_trigger_test.go
+++ b/tests/integration/actions_trigger_test.go
@@ -5,6 +5,7 @@ package integration
 
 import (
 	"fmt"
+	"net/http"
 	"net/url"
 	"strings"
 	"testing"
@@ -22,6 +23,7 @@ import (
 	actions_module "code.gitea.io/gitea/modules/actions"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/gitrepo"
+	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/setting"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/test"
@@ -72,9 +74,19 @@ func TestPullRequestTargetEvent(t *testing.T) {
 		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user2, &files_service.ChangeRepoFilesOptions{
 			Files: []*files_service.ChangeRepoFile{
 				{
-					Operation:     "create",
-					TreePath:      ".gitea/workflows/pr.yml",
-					ContentReader: strings.NewReader("name: test\non:\n  pull_request_target:\n    paths:\n      - 'file_*.txt'\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+					Operation: "create",
+					TreePath:  ".gitea/workflows/pr.yml",
+					ContentReader: strings.NewReader(`name: test
+on:
+  pull_request_target:
+    paths:
+      - 'file_*.txt'
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo helloworld
+`),
 				},
 			},
 			Message:   "add workflow",
@@ -228,9 +240,19 @@ func TestSkipCI(t *testing.T) {
 		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
 			Files: []*files_service.ChangeRepoFile{
 				{
-					Operation:     "create",
-					TreePath:      ".gitea/workflows/pr.yml",
-					ContentReader: strings.NewReader("name: test\non:\n  push:\n    branches: [master]\n  pull_request:\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+					Operation: "create",
+					TreePath:  ".gitea/workflows/pr.yml",
+					ContentReader: strings.NewReader(`name: test
+on:
+  push:
+    branches: [master]
+  pull_request:
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo helloworld
+`),
 				},
 			},
 			Message:   "add workflow",
@@ -347,9 +369,17 @@ func TestCreateDeleteRefEvent(t *testing.T) {
 		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
 			Files: []*files_service.ChangeRepoFile{
 				{
-					Operation:     "create",
-					TreePath:      ".gitea/workflows/createdelete.yml",
-					ContentReader: strings.NewReader("name: test\non:\n  [create,delete]\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+					Operation: "create",
+					TreePath:  ".gitea/workflows/createdelete.yml",
+					ContentReader: strings.NewReader(`name: test
+on:
+  [create,delete]
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo helloworld
+`),
 				},
 			},
 			Message:   "add workflow",
@@ -461,9 +491,18 @@ func TestPullRequestCommitStatusEvent(t *testing.T) {
 		addWorkflow, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
 			Files: []*files_service.ChangeRepoFile{
 				{
-					Operation:     "create",
-					TreePath:      ".gitea/workflows/pr.yml",
-					ContentReader: strings.NewReader("name: test\non:\n  pull_request:\n    types: [assigned, unassigned, labeled, unlabeled, opened, edited, closed, reopened, synchronize, milestoned, demilestoned, review_requested, review_request_removed]\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+					Operation: "create",
+					TreePath:  ".gitea/workflows/pr.yml",
+					ContentReader: strings.NewReader(`name: test
+on:
+  pull_request:
+    types: [assigned, unassigned, labeled, unlabeled, opened, edited, closed, reopened, synchronize, milestoned, demilestoned, review_requested, review_request_removed]
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo helloworld
+`),
 				},
 			},
 			Message:   "add workflow",
@@ -651,3 +690,681 @@ func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha, targetURL,
 	})
 	assert.NoError(t, err)
 }
+
+func TestWorkflowDispatchPublicApi(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-dispatch-event",
+			Description:   "test workflow-dispatch ci event",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation: "create",
+					TreePath:  ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader(`name: test
+on:
+  workflow_dispatch
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo helloworld
+`),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// Get the commit ID of the default branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
+		assert.NoError(t, err)
+		values := url.Values{}
+		values.Set("ref", "main")
+		req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), values).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/main",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  branch.CommitID,
+		})
+		assert.NotNil(t, run)
+	})
+}
+
+func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-dispatch-event",
+			Description:   "test workflow-dispatch ci event",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation: "create",
+					TreePath:  ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader(`name: test
+on:
+  workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo helloworld
+`),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// Get the commit ID of the default branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
+		assert.NoError(t, err)
+		values := url.Values{}
+		values.Set("ref", "main")
+		values.Set("inputs[myinput]", "val0")
+		values.Set("inputs[myinput3]", "true")
+		req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), values).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/main",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  branch.CommitID,
+		})
+		assert.NotNil(t, run)
+		dispatchPayload := &api.WorkflowDispatchPayload{}
+		err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload)
+		assert.NoError(t, err)
+		assert.Contains(t, dispatchPayload.Inputs, "myinput")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput2")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput3")
+		assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
+		assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
+		assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
+	})
+}
+
+func TestWorkflowDispatchPublicApiJSON(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-dispatch-event",
+			Description:   "test workflow-dispatch ci event",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation: "create",
+					TreePath:  ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader(`name: test
+on:
+  workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo helloworld
+`),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// Get the commit ID of the default branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
+		assert.NoError(t, err)
+		inputs := &api.CreateActionWorkflowDispatch{
+			Ref: "main",
+			Inputs: map[string]string{
+				"myinput":  "val0",
+				"myinput3": "true",
+			},
+		}
+
+		req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/main",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  branch.CommitID,
+		})
+		assert.NotNil(t, run)
+	})
+}
+
+func TestWorkflowDispatchPublicApiWithInputsJSON(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-dispatch-event",
+			Description:   "test workflow-dispatch ci event",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation: "create",
+					TreePath:  ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader(`name: test
+on:
+  workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo helloworld
+`),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// Get the commit ID of the default branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
+		assert.NoError(t, err)
+		inputs := &api.CreateActionWorkflowDispatch{
+			Ref: "main",
+			Inputs: map[string]string{
+				"myinput":  "val0",
+				"myinput3": "true",
+			},
+		}
+		req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/main",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  branch.CommitID,
+		})
+		assert.NotNil(t, run)
+		dispatchPayload := &api.WorkflowDispatchPayload{}
+		err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload)
+		assert.NoError(t, err)
+		assert.Contains(t, dispatchPayload.Inputs, "myinput")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput2")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput3")
+		assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
+		assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
+		assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
+	})
+}
+
+func TestWorkflowDispatchPublicApiWithInputsNonDefaultBranchJSON(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-dispatch-event",
+			Description:   "test workflow-dispatch ci event",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation: "create",
+					TreePath:  ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader(`name: test
+on:
+  workflow_dispatch
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo helloworld
+`),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err = files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation: "update",
+					TreePath:  ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader(`name: test
+on:
+  workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo helloworld
+`),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "dispatch",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// Get the commit ID of the dispatch branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		commit, err := gitRepo.GetBranchCommit("dispatch")
+		assert.NoError(t, err)
+		inputs := &api.CreateActionWorkflowDispatch{
+			Ref: "refs/heads/dispatch",
+			Inputs: map[string]string{
+				"myinput":  "val0",
+				"myinput3": "true",
+			},
+		}
+		req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/dispatch",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  commit.ID.String(),
+		})
+		assert.NotNil(t, run)
+		dispatchPayload := &api.WorkflowDispatchPayload{}
+		err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload)
+		assert.NoError(t, err)
+		assert.Contains(t, dispatchPayload.Inputs, "myinput")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput2")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput3")
+		assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
+		assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
+		assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
+	})
+}
+
+func TestWorkflowApi(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-api",
+			Description:   "test workflow apis",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/workflows", repo.FullName())).
+			AddTokenAuth(token)
+		resp := MakeRequest(t, req, http.StatusOK)
+		workflows := &api.ActionWorkflowResponse{}
+		json.NewDecoder(resp.Body).Decode(workflows)
+		assert.Empty(t, workflows.Workflows)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation: "create",
+					TreePath:  ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader(`name: test
+on:
+  workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo helloworld
+`),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/workflows", repo.FullName())).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		json.NewDecoder(resp.Body).Decode(workflows)
+		assert.Len(t, workflows.Workflows, 1)
+		assert.Equal(t, "dispatch.yml", workflows.Workflows[0].Name)
+		assert.Equal(t, ".gitea/workflows/dispatch.yml", workflows.Workflows[0].Path)
+		assert.Equal(t, ".gitea/workflows/dispatch.yml", workflows.Workflows[0].Path)
+		assert.Equal(t, "active", workflows.Workflows[0].State)
+
+		// Use a hardcoded api path
+		req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/%s", repo.FullName(), workflows.Workflows[0].ID)).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		workflow := &api.ActionWorkflow{}
+		json.NewDecoder(resp.Body).Decode(workflow)
+		assert.Equal(t, workflows.Workflows[0].ID, workflow.ID)
+		assert.Equal(t, workflows.Workflows[0].Path, workflow.Path)
+		assert.Equal(t, workflows.Workflows[0].URL, workflow.URL)
+		assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL)
+		assert.Equal(t, workflows.Workflows[0].Name, workflow.Name)
+		assert.Equal(t, workflows.Workflows[0].State, workflow.State)
+
+		// Use the provided url instead of the hardcoded one
+		req = NewRequest(t, "GET", workflows.Workflows[0].URL).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		workflow = &api.ActionWorkflow{}
+		json.NewDecoder(resp.Body).Decode(workflow)
+		assert.Equal(t, workflows.Workflows[0].ID, workflow.ID)
+		assert.Equal(t, workflows.Workflows[0].Path, workflow.Path)
+		assert.Equal(t, workflows.Workflows[0].URL, workflow.URL)
+		assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL)
+		assert.Equal(t, workflows.Workflows[0].Name, workflow.Name)
+		assert.Equal(t, workflows.Workflows[0].State, workflow.State)
+
+		// Disable the workflow
+		req = NewRequest(t, "PUT", workflows.Workflows[0].URL+"/disable").
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		// Use the provided url instead of the hardcoded one
+		req = NewRequest(t, "GET", workflows.Workflows[0].URL).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		workflow = &api.ActionWorkflow{}
+		json.NewDecoder(resp.Body).Decode(workflow)
+		assert.Equal(t, workflows.Workflows[0].ID, workflow.ID)
+		assert.Equal(t, workflows.Workflows[0].Path, workflow.Path)
+		assert.Equal(t, workflows.Workflows[0].URL, workflow.URL)
+		assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL)
+		assert.Equal(t, workflows.Workflows[0].Name, workflow.Name)
+		assert.Equal(t, "disabled_manually", workflow.State)
+
+		inputs := &api.CreateActionWorkflowDispatch{
+			Ref: "main",
+			Inputs: map[string]string{
+				"myinput":  "val0",
+				"myinput3": "true",
+			},
+		}
+		// Since the workflow is disabled, so the response code is 403 forbidden
+		req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusForbidden)
+
+		// Enable the workflow again
+		req = NewRequest(t, "PUT", workflows.Workflows[0].URL+"/enable").
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		// Use the provided url instead of the hardcoded one
+		req = NewRequest(t, "GET", workflows.Workflows[0].URL).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		workflow = &api.ActionWorkflow{}
+		json.NewDecoder(resp.Body).Decode(workflow)
+		assert.Equal(t, workflows.Workflows[0].ID, workflow.ID)
+		assert.Equal(t, workflows.Workflows[0].Path, workflow.Path)
+		assert.Equal(t, workflows.Workflows[0].URL, workflow.URL)
+		assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL)
+		assert.Equal(t, workflows.Workflows[0].Name, workflow.Name)
+		assert.Equal(t, workflows.Workflows[0].State, workflow.State)
+
+		req = NewRequest(t, "GET", workflows.Workflows[0].URL).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		workflow = &api.ActionWorkflow{}
+		json.NewDecoder(resp.Body).Decode(workflow)
+		assert.Equal(t, workflows.Workflows[0].ID, workflow.ID)
+		assert.Equal(t, workflows.Workflows[0].Path, workflow.Path)
+		assert.Equal(t, workflows.Workflows[0].URL, workflow.URL)
+		assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL)
+		assert.Equal(t, workflows.Workflows[0].Name, workflow.Name)
+		assert.Equal(t, workflows.Workflows[0].State, workflow.State)
+
+		// Get the commit ID of the default branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
+		assert.NoError(t, err)
+		inputs = &api.CreateActionWorkflowDispatch{
+			Ref: "main",
+			Inputs: map[string]string{
+				"myinput":  "val0",
+				"myinput3": "true",
+			},
+		}
+		req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/main",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  branch.CommitID,
+		})
+		assert.NotNil(t, run)
+		dispatchPayload := &api.WorkflowDispatchPayload{}
+		err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload)
+		assert.NoError(t, err)
+		assert.Contains(t, dispatchPayload.Inputs, "myinput")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput2")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput3")
+		assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
+		assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
+		assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
+	})
+}
diff --git a/tests/integration/actions_variables_test.go b/tests/integration/actions_variables_test.go
new file mode 100644
index 0000000000..12c1c3f628
--- /dev/null
+++ b/tests/integration/actions_variables_test.go
@@ -0,0 +1,149 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"testing"
+
+	actions_model "code.gitea.io/gitea/models/actions"
+	"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"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestActionsVariables(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	ctx := context.Background()
+
+	require.NoError(t, db.DeleteAllRecords("action_variable"))
+
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	_, _ = actions_model.InsertVariable(ctx, user2.ID, 0, "VAR", "user2-var")
+	user2Var := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionVariable{OwnerID: user2.ID, Name: "VAR"})
+	userWebURL := "/user/settings/actions/variables"
+
+	org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
+	_, _ = actions_model.InsertVariable(ctx, org3.ID, 0, "VAR", "org3-var")
+	org3Var := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionVariable{OwnerID: org3.ID, Name: "VAR"})
+	orgWebURL := "/org/org3/settings/actions/variables"
+
+	repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	_, _ = actions_model.InsertVariable(ctx, 0, repo1.ID, "VAR", "repo1-var")
+	repo1Var := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionVariable{RepoID: repo1.ID, Name: "VAR"})
+	repoWebURL := "/user2/repo1/settings/actions/variables"
+
+	_, _ = actions_model.InsertVariable(ctx, 0, 0, "VAR", "global-var")
+	globalVar := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionVariable{Name: "VAR", Data: "global-var"})
+	adminWebURL := "/-/admin/actions/variables"
+
+	sessionAdmin := loginUser(t, "user1")
+	sessionUser2 := loginUser(t, user2.Name)
+
+	doUpdate := func(t *testing.T, sess *TestSession, baseURL string, id int64, data string, expectedStatus int) {
+		req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/%d/edit", baseURL, id), map[string]string{
+			"_csrf": GetUserCSRFToken(t, sess),
+			"name":  "VAR",
+			"data":  data,
+		})
+		sess.MakeRequest(t, req, expectedStatus)
+	}
+
+	doDelete := func(t *testing.T, sess *TestSession, baseURL string, id int64, expectedStatus int) {
+		req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/%d/delete", baseURL, id), map[string]string{
+			"_csrf": GetUserCSRFToken(t, sess),
+		})
+		sess.MakeRequest(t, req, expectedStatus)
+	}
+
+	assertDenied := func(t *testing.T, sess *TestSession, baseURL string, id int64) {
+		doUpdate(t, sess, baseURL, id, "ChangedData", http.StatusNotFound)
+		doDelete(t, sess, baseURL, id, http.StatusNotFound)
+		v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionVariable{ID: id})
+		assert.Contains(t, v.Data, "-var")
+	}
+
+	assertSuccess := func(t *testing.T, sess *TestSession, baseURL string, id int64) {
+		doUpdate(t, sess, baseURL, id, "ChangedData", http.StatusOK)
+		v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionVariable{ID: id})
+		assert.Equal(t, "ChangedData", v.Data)
+		doDelete(t, sess, baseURL, id, http.StatusOK)
+		unittest.AssertNotExistsBean(t, &actions_model.ActionVariable{ID: id})
+	}
+
+	t.Run("UpdateUserVar", func(t *testing.T) {
+		theVar := user2Var
+		t.Run("FromOrg", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, orgWebURL, theVar.ID)
+		})
+		t.Run("FromRepo", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, repoWebURL, theVar.ID)
+		})
+		t.Run("FromAdmin", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, adminWebURL, theVar.ID)
+		})
+	})
+
+	t.Run("UpdateOrgVar", func(t *testing.T) {
+		theVar := org3Var
+		t.Run("FromRepo", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, repoWebURL, theVar.ID)
+		})
+		t.Run("FromUser", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, userWebURL, theVar.ID)
+		})
+		t.Run("FromAdmin", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, adminWebURL, theVar.ID)
+		})
+	})
+
+	t.Run("UpdateRepoVar", func(t *testing.T) {
+		theVar := repo1Var
+		t.Run("FromOrg", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, orgWebURL, theVar.ID)
+		})
+		t.Run("FromUser", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, userWebURL, theVar.ID)
+		})
+		t.Run("FromAdmin", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, adminWebURL, theVar.ID)
+		})
+	})
+
+	t.Run("UpdateGlobalVar", func(t *testing.T) {
+		theVar := globalVar
+		t.Run("FromOrg", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, orgWebURL, theVar.ID)
+		})
+		t.Run("FromUser", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, userWebURL, theVar.ID)
+		})
+		t.Run("FromRepo", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, repoWebURL, theVar.ID)
+		})
+	})
+
+	t.Run("UpdateSuccess", func(t *testing.T) {
+		t.Run("User", func(t *testing.T) {
+			assertSuccess(t, sessionUser2, userWebURL, user2Var.ID)
+		})
+		t.Run("Org", func(t *testing.T) {
+			assertSuccess(t, sessionAdmin, orgWebURL, org3Var.ID)
+		})
+		t.Run("Repo", func(t *testing.T) {
+			assertSuccess(t, sessionUser2, repoWebURL, repo1Var.ID)
+		})
+		t.Run("Admin", func(t *testing.T) {
+			assertSuccess(t, sessionAdmin, adminWebURL, globalVar.ID)
+		})
+	})
+}
diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go
index fff121490c..d766b1e8be 100644
--- a/tests/integration/api_org_test.go
+++ b/tests/integration/api_org_test.go
@@ -6,7 +6,6 @@ package integration
 import (
 	"fmt"
 	"net/http"
-	"net/url"
 	"strings"
 	"testing"
 
@@ -19,46 +18,52 @@ import (
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/setting"
 	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/test"
 	"code.gitea.io/gitea/tests"
 
 	"github.com/stretchr/testify/assert"
 )
 
-func TestAPIOrgCreate(t *testing.T) {
-	onGiteaRun(t, func(*testing.T, *url.URL) {
-		token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization)
+func TestAPIOrgCreateRename(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+	token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization)
 
-		org := api.CreateOrgOption{
-			UserName:    "user1_org",
-			FullName:    "User1's organization",
-			Description: "This organization created by user1",
-			Website:     "https://try.gitea.io",
-			Location:    "Shanghai",
-			Visibility:  "limited",
-		}
-		req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &org).
-			AddTokenAuth(token)
-		resp := MakeRequest(t, req, http.StatusCreated)
+	org := api.CreateOrgOption{
+		UserName:    "user1_org",
+		FullName:    "User1's organization",
+		Description: "This organization created by user1",
+		Website:     "https://try.gitea.io",
+		Location:    "Shanghai",
+		Visibility:  "limited",
+	}
+	req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &org).AddTokenAuth(token)
+	resp := MakeRequest(t, req, http.StatusCreated)
 
-		var apiOrg api.Organization
-		DecodeJSON(t, resp, &apiOrg)
+	var apiOrg api.Organization
+	DecodeJSON(t, resp, &apiOrg)
 
-		assert.Equal(t, org.UserName, apiOrg.Name)
-		assert.Equal(t, org.FullName, apiOrg.FullName)
-		assert.Equal(t, org.Description, apiOrg.Description)
-		assert.Equal(t, org.Website, apiOrg.Website)
-		assert.Equal(t, org.Location, apiOrg.Location)
-		assert.Equal(t, org.Visibility, apiOrg.Visibility)
+	assert.Equal(t, org.UserName, apiOrg.Name)
+	assert.Equal(t, org.FullName, apiOrg.FullName)
+	assert.Equal(t, org.Description, apiOrg.Description)
+	assert.Equal(t, org.Website, apiOrg.Website)
+	assert.Equal(t, org.Location, apiOrg.Location)
+	assert.Equal(t, org.Visibility, apiOrg.Visibility)
 
-		unittest.AssertExistsAndLoadBean(t, &user_model.User{
-			Name:      org.UserName,
-			LowerName: strings.ToLower(org.UserName),
-			FullName:  org.FullName,
-		})
+	unittest.AssertExistsAndLoadBean(t, &user_model.User{
+		Name:      org.UserName,
+		LowerName: strings.ToLower(org.UserName),
+		FullName:  org.FullName,
+	})
 
+	// check org name
+	req = NewRequestf(t, "GET", "/api/v1/orgs/%s", org.UserName).AddTokenAuth(token)
+	resp = MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, resp, &apiOrg)
+	assert.EqualValues(t, org.UserName, apiOrg.Name)
+
+	t.Run("CheckPermission", func(t *testing.T) {
 		// Check owner team permission
 		ownerTeam, _ := org_model.GetOwnerTeam(db.DefaultContext, apiOrg.ID)
-
 		for _, ut := range unit_model.AllRepoUnitTypes {
 			up := perm.AccessModeOwner
 			if ut == unit_model.TypeExternalTracker || ut == unit_model.TypeExternalWiki {
@@ -71,25 +76,10 @@ func TestAPIOrgCreate(t *testing.T) {
 				AccessMode: up,
 			})
 		}
+	})
 
-		req = NewRequestf(t, "GET", "/api/v1/orgs/%s", org.UserName).
-			AddTokenAuth(token)
-		resp = MakeRequest(t, req, http.StatusOK)
-		DecodeJSON(t, resp, &apiOrg)
-		assert.EqualValues(t, org.UserName, apiOrg.Name)
-
-		req = NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", org.UserName).
-			AddTokenAuth(token)
-		resp = MakeRequest(t, req, http.StatusOK)
-
-		var repos []*api.Repository
-		DecodeJSON(t, resp, &repos)
-		for _, repo := range repos {
-			assert.False(t, repo.Private)
-		}
-
-		req = NewRequestf(t, "GET", "/api/v1/orgs/%s/members", org.UserName).
-			AddTokenAuth(token)
+	t.Run("CheckMembers", func(t *testing.T) {
+		req = NewRequestf(t, "GET", "/api/v1/orgs/%s/members", org.UserName).AddTokenAuth(token)
 		resp = MakeRequest(t, req, http.StatusOK)
 
 		// user1 on this org is public
@@ -98,76 +88,89 @@ func TestAPIOrgCreate(t *testing.T) {
 		assert.Len(t, users, 1)
 		assert.EqualValues(t, "user1", users[0].UserName)
 	})
+
+	t.Run("RenameOrg", func(t *testing.T) {
+		req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/user1_org/rename", &api.RenameOrgOption{
+			NewName: "renamed_org",
+		}).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNoContent)
+		unittest.AssertExistsAndLoadBean(t, &org_model.Organization{Name: "renamed_org"})
+		org.UserName = "renamed_org" // update the variable so the following tests could still use it
+	})
+
+	t.Run("ListRepos", func(t *testing.T) {
+		// FIXME: this test is wrong, there is no repository at all, so the for-loop is empty
+		req = NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", org.UserName).AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		var repos []*api.Repository
+		DecodeJSON(t, resp, &repos)
+		for _, repo := range repos {
+			assert.False(t, repo.Private)
+		}
+	})
 }
 
 func TestAPIOrgEdit(t *testing.T) {
-	onGiteaRun(t, func(*testing.T, *url.URL) {
-		session := loginUser(t, "user1")
+	defer tests.PrepareTestEnv(t)()
+	session := loginUser(t, "user1")
 
-		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
-		org := api.EditOrgOption{
-			FullName:    "Org3 organization new full name",
-			Description: "A new description",
-			Website:     "https://try.gitea.io/new",
-			Location:    "Beijing",
-			Visibility:  "private",
-		}
-		req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).
-			AddTokenAuth(token)
-		resp := MakeRequest(t, req, http.StatusOK)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+	org := api.EditOrgOption{
+		FullName:    "Org3 organization new full name",
+		Description: "A new description",
+		Website:     "https://try.gitea.io/new",
+		Location:    "Beijing",
+		Visibility:  "private",
+	}
+	req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).
+		AddTokenAuth(token)
+	resp := MakeRequest(t, req, http.StatusOK)
 
-		var apiOrg api.Organization
-		DecodeJSON(t, resp, &apiOrg)
+	var apiOrg api.Organization
+	DecodeJSON(t, resp, &apiOrg)
 
-		assert.Equal(t, "org3", apiOrg.Name)
-		assert.Equal(t, org.FullName, apiOrg.FullName)
-		assert.Equal(t, org.Description, apiOrg.Description)
-		assert.Equal(t, org.Website, apiOrg.Website)
-		assert.Equal(t, org.Location, apiOrg.Location)
-		assert.Equal(t, org.Visibility, apiOrg.Visibility)
-	})
+	assert.Equal(t, "org3", apiOrg.Name)
+	assert.Equal(t, org.FullName, apiOrg.FullName)
+	assert.Equal(t, org.Description, apiOrg.Description)
+	assert.Equal(t, org.Website, apiOrg.Website)
+	assert.Equal(t, org.Location, apiOrg.Location)
+	assert.Equal(t, org.Visibility, apiOrg.Visibility)
 }
 
 func TestAPIOrgEditBadVisibility(t *testing.T) {
-	onGiteaRun(t, func(*testing.T, *url.URL) {
-		session := loginUser(t, "user1")
+	defer tests.PrepareTestEnv(t)()
+	session := loginUser(t, "user1")
 
-		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
-		org := api.EditOrgOption{
-			FullName:    "Org3 organization new full name",
-			Description: "A new description",
-			Website:     "https://try.gitea.io/new",
-			Location:    "Beijing",
-			Visibility:  "badvisibility",
-		}
-		req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).
-			AddTokenAuth(token)
-		MakeRequest(t, req, http.StatusUnprocessableEntity)
-	})
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+	org := api.EditOrgOption{
+		FullName:    "Org3 organization new full name",
+		Description: "A new description",
+		Website:     "https://try.gitea.io/new",
+		Location:    "Beijing",
+		Visibility:  "badvisibility",
+	}
+	req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).
+		AddTokenAuth(token)
+	MakeRequest(t, req, http.StatusUnprocessableEntity)
 }
 
 func TestAPIOrgDeny(t *testing.T) {
-	onGiteaRun(t, func(*testing.T, *url.URL) {
-		setting.Service.RequireSignInView = true
-		defer func() {
-			setting.Service.RequireSignInView = false
-		}()
+	defer tests.PrepareTestEnv(t)()
+	defer test.MockVariableValue(&setting.Service.RequireSignInView, true)()
 
-		orgName := "user1_org"
-		req := NewRequestf(t, "GET", "/api/v1/orgs/%s", orgName)
-		MakeRequest(t, req, http.StatusNotFound)
+	orgName := "user1_org"
+	req := NewRequestf(t, "GET", "/api/v1/orgs/%s", orgName)
+	MakeRequest(t, req, http.StatusNotFound)
 
-		req = NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", orgName)
-		MakeRequest(t, req, http.StatusNotFound)
+	req = NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", orgName)
+	MakeRequest(t, req, http.StatusNotFound)
 
-		req = NewRequestf(t, "GET", "/api/v1/orgs/%s/members", orgName)
-		MakeRequest(t, req, http.StatusNotFound)
-	})
+	req = NewRequestf(t, "GET", "/api/v1/orgs/%s/members", orgName)
+	MakeRequest(t, req, http.StatusNotFound)
 }
 
 func TestAPIGetAll(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
-
 	token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization)
 
 	// accessing with a token will return all orgs
@@ -192,37 +195,36 @@ func TestAPIGetAll(t *testing.T) {
 }
 
 func TestAPIOrgSearchEmptyTeam(t *testing.T) {
-	onGiteaRun(t, func(*testing.T, *url.URL) {
-		token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization)
-		orgName := "org_with_empty_team"
+	defer tests.PrepareTestEnv(t)()
+	token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization)
+	orgName := "org_with_empty_team"
 
-		// create org
-		req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{
-			UserName: orgName,
-		}).AddTokenAuth(token)
-		MakeRequest(t, req, http.StatusCreated)
+	// create org
+	req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{
+		UserName: orgName,
+	}).AddTokenAuth(token)
+	MakeRequest(t, req, http.StatusCreated)
 
-		// create team with no member
-		req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), &api.CreateTeamOption{
-			Name:                    "Empty",
-			IncludesAllRepositories: true,
-			Permission:              "read",
-			Units:                   []string{"repo.code", "repo.issues", "repo.ext_issues", "repo.wiki", "repo.pulls"},
-		}).AddTokenAuth(token)
-		MakeRequest(t, req, http.StatusCreated)
+	// create team with no member
+	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), &api.CreateTeamOption{
+		Name:                    "Empty",
+		IncludesAllRepositories: true,
+		Permission:              "read",
+		Units:                   []string{"repo.code", "repo.issues", "repo.ext_issues", "repo.wiki", "repo.pulls"},
+	}).AddTokenAuth(token)
+	MakeRequest(t, req, http.StatusCreated)
 
-		// case-insensitive search for teams that have no members
-		req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/teams/search?q=%s", orgName, "empty")).
-			AddTokenAuth(token)
-		resp := MakeRequest(t, req, http.StatusOK)
-		data := struct {
-			Ok   bool
-			Data []*api.Team
-		}{}
-		DecodeJSON(t, resp, &data)
-		assert.True(t, data.Ok)
-		if assert.Len(t, data.Data, 1) {
-			assert.EqualValues(t, "Empty", data.Data[0].Name)
-		}
-	})
+	// case-insensitive search for teams that have no members
+	req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/teams/search?q=%s", orgName, "empty")).
+		AddTokenAuth(token)
+	resp := MakeRequest(t, req, http.StatusOK)
+	data := struct {
+		Ok   bool
+		Data []*api.Team
+	}{}
+	DecodeJSON(t, resp, &data)
+	assert.True(t, data.Ok)
+	if assert.Len(t, data.Data, 1) {
+		assert.EqualValues(t, "Empty", data.Data[0].Name)
+	}
 }
diff --git a/tests/integration/api_user_star_test.go b/tests/integration/api_user_star_test.go
index 0062889a92..368756528a 100644
--- a/tests/integration/api_user_star_test.go
+++ b/tests/integration/api_user_star_test.go
@@ -11,7 +11,9 @@ import (
 	auth_model "code.gitea.io/gitea/models/auth"
 	"code.gitea.io/gitea/models/unittest"
 	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/setting"
 	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/test"
 	"code.gitea.io/gitea/tests"
 
 	"github.com/stretchr/testify/assert"
@@ -91,3 +93,65 @@ func TestAPIStar(t *testing.T) {
 		MakeRequest(t, req, http.StatusNoContent)
 	})
 }
+
+func TestAPIStarDisabled(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	user := "user1"
+	repo := "user2/repo1"
+
+	session := loginUser(t, user)
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
+	tokenWithUserScope := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
+
+	defer test.MockVariableValue(&setting.Repository.DisableStars, true)()
+
+	t.Run("Star", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/starred/%s", repo)).
+			AddTokenAuth(tokenWithUserScope)
+		MakeRequest(t, req, http.StatusForbidden)
+
+		user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34})
+		req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/starred/%s", repo)).
+			AddTokenAuth(getUserToken(t, user34.Name, auth_model.AccessTokenScopeWriteRepository))
+		MakeRequest(t, req, http.StatusForbidden)
+	})
+
+	t.Run("GetStarredRepos", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/starred", user)).
+			AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusForbidden)
+	})
+
+	t.Run("GetMyStarredRepos", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "GET", "/api/v1/user/starred").
+			AddTokenAuth(tokenWithUserScope)
+		MakeRequest(t, req, http.StatusForbidden)
+	})
+
+	t.Run("IsStarring", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/starred/%s", repo)).
+			AddTokenAuth(tokenWithUserScope)
+		MakeRequest(t, req, http.StatusForbidden)
+
+		req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/starred/%s", repo+"notexisting")).
+			AddTokenAuth(tokenWithUserScope)
+		MakeRequest(t, req, http.StatusForbidden)
+	})
+
+	t.Run("Unstar", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/starred/%s", repo)).
+			AddTokenAuth(tokenWithUserScope)
+		MakeRequest(t, req, http.StatusForbidden)
+	})
+}
diff --git a/tests/integration/auth_ldap_test.go b/tests/integration/auth_ldap_test.go
index 5c50fd0288..0599c43805 100644
--- a/tests/integration/auth_ldap_test.go
+++ b/tests/integration/auth_ldap_test.go
@@ -279,7 +279,7 @@ func TestLDAPUserSyncWithEmptyUsernameAttribute(t *testing.T) {
 
 		htmlDoc := NewHTMLParser(t, resp.Body)
 
-		tr := htmlDoc.doc.Find("table.table tbody tr")
+		tr := htmlDoc.doc.Find("table.table tbody tr:not(.no-results-row)")
 		assert.Equal(t, 0, tr.Length())
 	}
 
diff --git a/tests/integration/benchmarks_test.go b/tests/integration/benchmarks_test.go
deleted file mode 100644
index 62da761d2d..0000000000
--- a/tests/integration/benchmarks_test.go
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright 2017 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package integration
-
-import (
-	"math/rand/v2"
-	"net/http"
-	"net/url"
-	"testing"
-
-	repo_model "code.gitea.io/gitea/models/repo"
-	"code.gitea.io/gitea/models/unittest"
-	api "code.gitea.io/gitea/modules/structs"
-)
-
-// StringWithCharset random string (from https://www.calhoun.io/creating-random-strings-in-go/)
-func StringWithCharset(length int, charset string) string {
-	b := make([]byte, length)
-	for i := range b {
-		b[i] = charset[rand.IntN(len(charset))]
-	}
-	return string(b)
-}
-
-func BenchmarkRepoBranchCommit(b *testing.B) {
-	onGiteaRun(b, func(b *testing.B, u *url.URL) {
-		samples := []int64{1, 2, 3}
-		b.ResetTimer()
-
-		for _, repoID := range samples {
-			b.StopTimer()
-			repo := unittest.AssertExistsAndLoadBean(b, &repo_model.Repository{ID: repoID})
-			b.StartTimer()
-			b.Run(repo.Name, func(b *testing.B) {
-				session := loginUser(b, "user2")
-				b.ResetTimer()
-				b.Run("CreateBranch", func(b *testing.B) {
-					b.StopTimer()
-					branchName := StringWithCharset(5+rand.IntN(10), "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
-					b.StartTimer()
-					for i := 0; i < b.N; i++ {
-						b.Run("new_"+branchName, func(b *testing.B) {
-							b.Skip("benchmark broken") // TODO fix
-							testAPICreateBranch(b, session, repo.OwnerName, repo.Name, repo.DefaultBranch, "new_"+branchName, http.StatusCreated)
-						})
-					}
-				})
-				b.Run("GetBranches", func(b *testing.B) {
-					req := NewRequestf(b, "GET", "/api/v1/repos/%s/branches", repo.FullName())
-					session.MakeRequest(b, req, http.StatusOK)
-				})
-				b.Run("AccessCommits", func(b *testing.B) {
-					var branches []*api.Branch
-					req := NewRequestf(b, "GET", "/api/v1/repos/%s/branches", repo.FullName())
-					resp := session.MakeRequest(b, req, http.StatusOK)
-					DecodeJSON(b, resp, &branches)
-					b.ResetTimer() // We measure from here
-					if len(branches) != 0 {
-						for i := 0; i < b.N; i++ {
-							req := NewRequestf(b, "GET", "/api/v1/repos/%s/commits?sha=%s", repo.FullName(), branches[i%len(branches)].Name)
-							session.MakeRequest(b, req, http.StatusOK)
-						}
-					}
-				})
-			})
-		}
-	})
-}
diff --git a/tests/integration/org_worktime_test.go b/tests/integration/org_worktime_test.go
new file mode 100644
index 0000000000..fb5216be8d
--- /dev/null
+++ b/tests/integration/org_worktime_test.go
@@ -0,0 +1,293 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration_test
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/organization"
+	"code.gitea.io/gitea/models/unittest"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestTimesByRepos tests TimesByRepos functionality
+func testTimesByRepos(t *testing.T) {
+	kases := []struct {
+		name     string
+		unixfrom int64
+		unixto   int64
+		orgname  int64
+		expected []organization.WorktimeSumByRepos
+	}{
+		{
+			name:     "Full sum for org 1",
+			unixfrom: 0,
+			unixto:   9223372036854775807,
+			orgname:  1,
+			expected: []organization.WorktimeSumByRepos(nil),
+		},
+		{
+			name:     "Full sum for org 2",
+			unixfrom: 0,
+			unixto:   9223372036854775807,
+			orgname:  2,
+			expected: []organization.WorktimeSumByRepos{
+				{
+					RepoName: "repo1",
+					SumTime:  4083,
+				},
+				{
+					RepoName: "repo2",
+					SumTime:  75,
+				},
+			},
+		},
+		{
+			name:     "Simple time bound",
+			unixfrom: 946684801,
+			unixto:   946684802,
+			orgname:  2,
+			expected: []organization.WorktimeSumByRepos{
+				{
+					RepoName: "repo1",
+					SumTime:  3662,
+				},
+			},
+		},
+		{
+			name:     "Both times inclusive",
+			unixfrom: 946684801,
+			unixto:   946684801,
+			orgname:  2,
+			expected: []organization.WorktimeSumByRepos{
+				{
+					RepoName: "repo1",
+					SumTime:  3661,
+				},
+			},
+		},
+		{
+			name:     "Should ignore deleted",
+			unixfrom: 947688814,
+			unixto:   947688815,
+			orgname:  2,
+			expected: []organization.WorktimeSumByRepos{
+				{
+					RepoName: "repo2",
+					SumTime:  71,
+				},
+			},
+		},
+	}
+
+	// Run test kases
+	for _, kase := range kases {
+		t.Run(kase.name, func(t *testing.T) {
+			org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname)
+			assert.NoError(t, err)
+			results, err := organization.GetWorktimeByRepos(org, kase.unixfrom, kase.unixto)
+			assert.NoError(t, err)
+			assert.Equal(t, kase.expected, results)
+		})
+	}
+}
+
+// TestTimesByMilestones tests TimesByMilestones functionality
+func testTimesByMilestones(t *testing.T) {
+	kases := []struct {
+		name     string
+		unixfrom int64
+		unixto   int64
+		orgname  int64
+		expected []organization.WorktimeSumByMilestones
+	}{
+		{
+			name:     "Full sum for org 1",
+			unixfrom: 0,
+			unixto:   9223372036854775807,
+			orgname:  1,
+			expected: []organization.WorktimeSumByMilestones(nil),
+		},
+		{
+			name:     "Full sum for org 2",
+			unixfrom: 0,
+			unixto:   9223372036854775807,
+			orgname:  2,
+			expected: []organization.WorktimeSumByMilestones{
+				{
+					RepoName:      "repo1",
+					MilestoneName: "",
+					MilestoneID:   0,
+					SumTime:       401,
+					HideRepoName:  false,
+				},
+				{
+					RepoName:      "repo1",
+					MilestoneName: "milestone1",
+					MilestoneID:   1,
+					SumTime:       3682,
+					HideRepoName:  true,
+				},
+				{
+					RepoName:      "repo2",
+					MilestoneName: "",
+					MilestoneID:   0,
+					SumTime:       75,
+					HideRepoName:  false,
+				},
+			},
+		},
+		{
+			name:     "Simple time bound",
+			unixfrom: 946684801,
+			unixto:   946684802,
+			orgname:  2,
+			expected: []organization.WorktimeSumByMilestones{
+				{
+					RepoName:      "repo1",
+					MilestoneName: "milestone1",
+					MilestoneID:   1,
+					SumTime:       3662,
+					HideRepoName:  false,
+				},
+			},
+		},
+		{
+			name:     "Both times inclusive",
+			unixfrom: 946684801,
+			unixto:   946684801,
+			orgname:  2,
+			expected: []organization.WorktimeSumByMilestones{
+				{
+					RepoName:      "repo1",
+					MilestoneName: "milestone1",
+					MilestoneID:   1,
+					SumTime:       3661,
+					HideRepoName:  false,
+				},
+			},
+		},
+		{
+			name:     "Should ignore deleted",
+			unixfrom: 947688814,
+			unixto:   947688815,
+			orgname:  2,
+			expected: []organization.WorktimeSumByMilestones{
+				{
+					RepoName:      "repo2",
+					MilestoneName: "",
+					MilestoneID:   0,
+					SumTime:       71,
+					HideRepoName:  false,
+				},
+			},
+		},
+	}
+
+	// Run test kases
+	for _, kase := range kases {
+		t.Run(kase.name, func(t *testing.T) {
+			org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname)
+			require.NoError(t, err)
+			results, err := organization.GetWorktimeByMilestones(org, kase.unixfrom, kase.unixto)
+			if assert.NoError(t, err) {
+				assert.Equal(t, kase.expected, results)
+			}
+		})
+	}
+}
+
+// TestTimesByMembers tests TimesByMembers functionality
+func testTimesByMembers(t *testing.T) {
+	kases := []struct {
+		name     string
+		unixfrom int64
+		unixto   int64
+		orgname  int64
+		expected []organization.WorktimeSumByMembers
+	}{
+		{
+			name:     "Full sum for org 1",
+			unixfrom: 0,
+			unixto:   9223372036854775807,
+			orgname:  1,
+			expected: []organization.WorktimeSumByMembers(nil),
+		},
+		{
+			// Test case: Sum of times forever in org no. 2
+			name:     "Full sum for org 2",
+			unixfrom: 0,
+			unixto:   9223372036854775807,
+			orgname:  2,
+			expected: []organization.WorktimeSumByMembers{
+				{
+					UserName: "user2",
+					SumTime:  3666,
+				},
+				{
+					UserName: "user1",
+					SumTime:  491,
+				},
+			},
+		},
+		{
+			name:     "Simple time bound",
+			unixfrom: 946684801,
+			unixto:   946684802,
+			orgname:  2,
+			expected: []organization.WorktimeSumByMembers{
+				{
+					UserName: "user2",
+					SumTime:  3662,
+				},
+			},
+		},
+		{
+			name:     "Both times inclusive",
+			unixfrom: 946684801,
+			unixto:   946684801,
+			orgname:  2,
+			expected: []organization.WorktimeSumByMembers{
+				{
+					UserName: "user2",
+					SumTime:  3661,
+				},
+			},
+		},
+		{
+			name:     "Should ignore deleted",
+			unixfrom: 947688814,
+			unixto:   947688815,
+			orgname:  2,
+			expected: []organization.WorktimeSumByMembers{
+				{
+					UserName: "user1",
+					SumTime:  71,
+				},
+			},
+		},
+	}
+
+	// Run test kases
+	for _, kase := range kases {
+		t.Run(kase.name, func(t *testing.T) {
+			org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname)
+			assert.NoError(t, err)
+			results, err := organization.GetWorktimeByMembers(org, kase.unixfrom, kase.unixto)
+			assert.NoError(t, err)
+			assert.Equal(t, kase.expected, results)
+		})
+	}
+}
+
+func TestOrgWorktime(t *testing.T) {
+	// we need to run these tests in integration test because there are complex SQL queries
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	t.Run("ByRepos", testTimesByRepos)
+	t.Run("ByMilestones", testTimesByMilestones)
+	t.Run("ByMembers", testTimesByMembers)
+}
diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go
index 17905513c3..2f9a815fef 100644
--- a/tests/integration/repo_webhook_test.go
+++ b/tests/integration/repo_webhook_test.go
@@ -16,6 +16,7 @@ import (
 	auth_model "code.gitea.io/gitea/models/auth"
 	"code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unittest"
+	"code.gitea.io/gitea/models/webhook"
 	"code.gitea.io/gitea/modules/gitrepo"
 	"code.gitea.io/gitea/modules/json"
 	api "code.gitea.io/gitea/modules/structs"
@@ -66,6 +67,19 @@ func testAPICreateWebhookForRepo(t *testing.T, session *TestSession, userName, r
 	MakeRequest(t, req, http.StatusCreated)
 }
 
+func testCreateWebhookForRepo(t *testing.T, session *TestSession, webhookType, userName, repoName, url, eventKind string) {
+	csrf := GetUserCSRFToken(t, session)
+	req := NewRequestWithValues(t, "POST", "/"+userName+"/"+repoName+"/settings/hooks/"+webhookType+"/new", map[string]string{
+		"_csrf":        csrf,
+		"payload_url":  url,
+		"events":       eventKind,
+		"active":       "true",
+		"content_type": fmt.Sprintf("%d", webhook.ContentTypeJSON),
+		"http_method":  "POST",
+	})
+	session.MakeRequest(t, req, http.StatusSeeOther)
+}
+
 func testAPICreateWebhookForOrg(t *testing.T, session *TestSession, userName, url, event string) {
 	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll)
 	req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/"+userName+"/hooks", api.CreateHookOption{
@@ -562,3 +576,28 @@ func Test_WebhookStatus(t *testing.T) {
 		assert.EqualValues(t, commitID, payloads[0].SHA)
 	})
 }
+
+func Test_WebhookStatus_NoWrongTrigger(t *testing.T) {
+	var trigger string
+	provider := newMockWebhookProvider(func(r *http.Request) {
+		assert.NotContains(t, r.Header["X-Github-Event-Type"], "status", "X-GitHub-Event-Type should not contain status")
+		assert.NotContains(t, r.Header["X-Gitea-Event-Type"], "status", "X-Gitea-Event-Type should not contain status")
+		assert.NotContains(t, r.Header["X-Gogs-Event-Type"], "status", "X-Gogs-Event-Type should not contain status")
+		trigger = "push"
+	}, http.StatusOK)
+	defer provider.Close()
+
+	onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+		// 1. create a new webhook with special webhook for repo1
+		session := loginUser(t, "user2")
+
+		// create a push_only webhook from web UI
+		testCreateWebhookForRepo(t, session, "gitea", "user2", "repo1", provider.URL(), "push_only")
+
+		// 2. trigger the webhook with a push action
+		testCreateFile(t, session, "user2", "repo1", "master", "test_webhook_push.md", "# a test file for webhook push")
+
+		// 3. validate the webhook is triggered with right event
+		assert.EqualValues(t, "push", trigger)
+	})
+}
diff --git a/web_src/css/features/cropper.css b/web_src/css/features/cropper.css
index ed7171e770..f7f8168006 100644
--- a/web_src/css/features/cropper.css
+++ b/web_src/css/features/cropper.css
@@ -1,6 +1,6 @@
 @import "cropperjs/dist/cropper.css";
 
-.page-content.user.profile .cropper-panel .cropper-wrapper {
+.avatar-file-with-cropper + .cropper-panel .cropper-wrapper {
   max-width: 400px;
   max-height: 400px;
 }
diff --git a/web_src/js/components/ActionRunStatus.vue b/web_src/js/components/ActionRunStatus.vue
index 96c6c441be..487d2460cc 100644
--- a/web_src/js/components/ActionRunStatus.vue
+++ b/web_src/js/components/ActionRunStatus.vue
@@ -19,12 +19,12 @@ withDefaults(defineProps<{
 
 <template>
   <span :data-tooltip-content="localeStatus ?? status" v-if="status">
-    <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class-name="className" v-if="status === 'success'"/>
-    <SvgIcon name="octicon-skip" class="text grey" :size="size" :class-name="className" v-else-if="status === 'skipped'"/>
-    <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'cancelled'"/>
-    <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'waiting'"/>
-    <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'blocked'"/>
-    <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class-name="'job-status-rotate ' + className" v-else-if="status === 'running'"/>
+    <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class="className" v-if="status === 'success'"/>
+    <SvgIcon name="octicon-skip" class="text grey" :size="size" :class="className" v-else-if="status === 'skipped'"/>
+    <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class="className" v-else-if="status === 'cancelled'"/>
+    <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class="className" v-else-if="status === 'waiting'"/>
+    <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/>
+    <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class="'job-status-rotate ' + className" v-else-if="status === 'running'"/>
     <SvgIcon name="octicon-x-circle-fill" class="text red" :size="size" v-else/><!-- failure, unknown -->
   </span>
 </template>
diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue
index 876292fc94..1840e89144 100644
--- a/web_src/js/components/DashboardRepoList.vue
+++ b/web_src/js/components/DashboardRepoList.vue
@@ -113,7 +113,7 @@ export default defineComponent({
     this.changeReposFilter(this.reposFilter);
     fomanticQuery(el.querySelector('.ui.dropdown')).dropdown();
     nextTick(() => {
-      this.$refs.search.focus();
+      this.$refs.search?.focus();
     });
 
     this.textArchivedFilterTitles = {
@@ -243,7 +243,7 @@ export default defineComponent({
         if (!this.reposTotalCount) {
           const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`;
           response = await GET(totalCountSearchURL);
-          this.reposTotalCount = response.headers.get('X-Total-Count') ?? '?';
+          this.reposTotalCount = parseInt(response.headers.get('X-Total-Count') ?? '0');
         }
 
         response = await GET(searchedURL);
@@ -336,7 +336,6 @@ export default defineComponent({
     },
   },
 });
-
 </script>
 <template>
   <div>
@@ -354,7 +353,15 @@ export default defineComponent({
           <svg-icon name="octicon-plus"/>
         </a>
       </h4>
-      <div class="ui attached segment repos-search">
+      <div v-if="!reposTotalCount" class="ui attached segment">
+        <div v-if="!isLoading" class="empty-repo-or-org">
+          <svg-icon name="octicon-git-branch" :size="24"/>
+          <p>{{ textNoRepo }}</p>
+        </div>
+        <!-- using the loading indicator here will cause more (unnecessary) page flickers, so at the moment, not use the loading indicator -->
+        <!-- <div v-else class="is-loading loading-icon-2px tw-min-h-16"/> -->
+      </div>
+      <div v-else class="ui attached segment repos-search">
         <div class="ui small fluid action left icon input">
           <input type="search" spellcheck="false" maxlength="255" @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" @keydown="reposFilterKeyControl" :placeholder="textSearchRepos">
           <i class="icon loading-icon-3px" :class="{'is-loading': isLoading}"><svg-icon name="octicon-search" :size="16"/></i>
@@ -367,7 +374,7 @@ export default defineComponent({
                       otherwise if the "input" handles click event for intermediate status, it breaks the internal state-->
                   <input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxArchivedFilterProps">
                   <label>
-                    <svg-icon name="octicon-archive" :size="16" class-name="tw-mr-1"/>
+                    <svg-icon name="octicon-archive" :size="16" class="tw-mr-1"/>
                     {{ textShowArchived }}
                   </label>
                 </div>
@@ -376,7 +383,7 @@ export default defineComponent({
                 <div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle">
                   <input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxPrivateFilterProps">
                   <label>
-                    <svg-icon name="octicon-lock" :size="16" class-name="tw-mr-1"/>
+                    <svg-icon name="octicon-lock" :size="16" class="tw-mr-1"/>
                     {{ textShowPrivate }}
                   </label>
                 </div>
@@ -413,7 +420,7 @@ export default defineComponent({
         <ul class="repo-owner-name-list">
           <li class="tw-flex tw-items-center tw-py-2" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id">
             <a class="repo-list-link muted" :href="repo.link">
-              <svg-icon :name="repoIcon(repo)" :size="16" class-name="repo-list-icon"/>
+              <svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/>
               <div class="text truncate">{{ repo.full_name }}</div>
               <div v-if="repo.archived">
                 <svg-icon name="octicon-archive" :size="16"/>
@@ -421,7 +428,7 @@ export default defineComponent({
             </a>
             <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link" :data-tooltip-content="repo.locale_latest_commit_status_state">
               <!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl -->
-              <svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class-name="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/>
+              <svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/>
             </a>
           </li>
         </ul>
@@ -432,26 +439,26 @@ export default defineComponent({
               class="item navigation tw-py-1" :class="{'disabled': page === 1}"
               @click="changePage(1)" :title="textFirstPage"
             >
-              <svg-icon name="gitea-double-chevron-left" :size="16" class-name="tw-mr-1"/>
+              <svg-icon name="gitea-double-chevron-left" :size="16" class="tw-mr-1"/>
             </a>
             <a
               class="item navigation tw-py-1" :class="{'disabled': page === 1}"
               @click="changePage(page - 1)" :title="textPreviousPage"
             >
-              <svg-icon name="octicon-chevron-left" :size="16" clsas-name="tw-mr-1"/>
+              <svg-icon name="octicon-chevron-left" :size="16" clsas="tw-mr-1"/>
             </a>
             <a class="active item tw-py-1">{{ page }}</a>
             <a
               class="item navigation" :class="{'disabled': page === finalPage}"
               @click="changePage(page + 1)" :title="textNextPage"
             >
-              <svg-icon name="octicon-chevron-right" :size="16" class-name="tw-ml-1"/>
+              <svg-icon name="octicon-chevron-right" :size="16" class="tw-ml-1"/>
             </a>
             <a
               class="item navigation tw-py-1" :class="{'disabled': page === finalPage}"
               @click="changePage(finalPage)" :title="textLastPage"
             >
-              <svg-icon name="gitea-double-chevron-right" :size="16" class-name="tw-ml-1"/>
+              <svg-icon name="gitea-double-chevron-right" :size="16" class="tw-ml-1"/>
             </a>
           </div>
         </div>
@@ -467,11 +474,17 @@ export default defineComponent({
           <svg-icon name="octicon-plus"/>
         </a>
       </h4>
-      <div v-if="organizations.length" class="ui attached table segment tw-rounded-b">
+      <div v-if="!organizations.length" class="ui attached segment">
+        <div class="empty-repo-or-org">
+          <svg-icon name="octicon-organization" :size="24"/>
+          <p>{{ textNoOrg }}</p>
+        </div>
+      </div>
+      <div v-else class="ui attached table segment tw-rounded-b">
         <ul class="repo-owner-name-list">
           <li class="tw-flex tw-items-center tw-py-2" v-for="org in organizations" :key="org.name">
             <a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)">
-              <svg-icon name="octicon-organization" :size="16" class-name="repo-list-icon"/>
+              <svg-icon name="octicon-organization" :size="16" class="repo-list-icon"/>
               <div class="text truncate">{{ org.full_name ? `${org.full_name} (${org.name})` : org.name }}</div>
               <div><!-- div to prevent underline of label on hover -->
                 <span class="ui tiny basic label" v-if="org.org_visibility !== 'public'">
@@ -481,7 +494,7 @@ export default defineComponent({
             </a>
             <div class="text light grey tw-flex tw-items-center tw-ml-2">
               {{ org.num_repos }}
-              <svg-icon name="octicon-repo" :size="16" class-name="tw-ml-1 tw-mt-0.5"/>
+              <svg-icon name="octicon-repo" :size="16" class="tw-ml-1 tw-mt-0.5"/>
             </div>
           </li>
         </ul>
@@ -546,4 +559,14 @@ ul li:not(:last-child) {
 .repo-owner-name-list li.active {
   background: var(--color-hover);
 }
+
+.empty-repo-or-org {
+  margin-top: 1em;
+  text-align: center;
+  color: var(--color-placeholder-text);
+}
+
+.empty-repo-or-org p {
+  margin: 1em auto;
+}
 </style>
diff --git a/web_src/js/components/RepoBranchTagSelector.vue b/web_src/js/components/RepoBranchTagSelector.vue
index fa5c75af99..820e69d9ab 100644
--- a/web_src/js/components/RepoBranchTagSelector.vue
+++ b/web_src/js/components/RepoBranchTagSelector.vue
@@ -226,7 +226,7 @@ export default defineComponent({
           <strong ref="dropdownRefName" class="tw-ml-2 tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong>
         </template>
       </span>
-      <svg-icon name="octicon-triangle-down" :size="14" class-name="dropdown icon"/>
+      <svg-icon name="octicon-triangle-down" :size="14" class="dropdown icon"/>
     </div>
     <div class="menu transition" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak>
       <div class="ui icon search input">
@@ -235,10 +235,10 @@ export default defineComponent({
       </div>
       <div v-if="showTabBranches" class="branch-tag-tab">
         <a class="branch-tag-item muted" :class="{active: selectedTab === 'branches'}" href="#" @click="handleTabSwitch('branches')">
-          <svg-icon name="octicon-git-branch" :size="16" class-name="tw-mr-1"/>{{ textBranches }}
+          <svg-icon name="octicon-git-branch" :size="16" class="tw-mr-1"/>{{ textBranches }}
         </a>
         <a v-if="showTabTags" class="branch-tag-item muted" :class="{active: selectedTab === 'tags'}" href="#" @click="handleTabSwitch('tags')">
-          <svg-icon name="octicon-tag" :size="16" class-name="tw-mr-1"/>{{ textTags }}
+          <svg-icon name="octicon-tag" :size="16" class="tw-mr-1"/>{{ textTags }}
         </a>
       </div>
       <div class="branch-tag-divider"/>
diff --git a/web_src/js/features/admin/common.ts b/web_src/js/features/admin/common.ts
index b991749d81..14a49af81e 100644
--- a/web_src/js/features/admin/common.ts
+++ b/web_src/js/features/admin/common.ts
@@ -1,7 +1,8 @@
 import $ from 'jquery';
 import {checkAppUrl} from '../common-page.ts';
-import {hideElem, showElem, toggleElem} from '../../utils/dom.ts';
+import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
 import {POST} from '../../modules/fetch.ts';
+import {initAvatarUploaderWithCropper} from '../comp/Cropper.ts';
 
 const {appSubUrl} = window.config;
 
@@ -258,4 +259,6 @@ export function initAdminCommon(): void {
       window.location.href = this.getAttribute('data-redirect');
     });
   }
+
+  queryElems(document, '.avatar-file-with-cropper', initAvatarUploaderWithCropper);
 }
diff --git a/web_src/js/features/common-organization.ts b/web_src/js/features/common-organization.ts
index a1f19bedea..9d5964c4c7 100644
--- a/web_src/js/features/common-organization.ts
+++ b/web_src/js/features/common-organization.ts
@@ -1,5 +1,6 @@
 import {initCompLabelEdit} from './comp/LabelEdit.ts';
-import {toggleElem} from '../utils/dom.ts';
+import {queryElems, toggleElem} from '../utils/dom.ts';
+import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
 
 export function initCommonOrganization() {
   if (!document.querySelectorAll('.organization').length) {
@@ -13,4 +14,6 @@ export function initCommonOrganization() {
 
   // Labels
   initCompLabelEdit('.page-content.organization.settings.labels');
+
+  queryElems(document, '.avatar-file-with-cropper', initAvatarUploaderWithCropper);
 }
diff --git a/web_src/js/features/comp/Cropper.ts b/web_src/js/features/comp/Cropper.ts
index e65dcfbe13..aaa1691152 100644
--- a/web_src/js/features/comp/Cropper.ts
+++ b/web_src/js/features/comp/Cropper.ts
@@ -6,7 +6,7 @@ type CropperOpts = {
   fileInput: HTMLInputElement,
 }
 
-export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
+async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
   const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
   let currentFileName = '';
   let currentFileLastModified = 0;
@@ -38,3 +38,10 @@ export async function initCompCropper({container, fileInput, imageSource}: Cropp
     }
   });
 }
+
+export async function initAvatarUploaderWithCropper(fileInput: HTMLInputElement) {
+  const panel = fileInput.nextElementSibling as HTMLElement;
+  if (!panel?.matches('.cropper-panel')) throw new Error('Missing cropper panel for avatar uploader');
+  const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source');
+  await initCompCropper({container: panel, fileInput, imageSource});
+}
diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts
index a0cb875a87..f5455393b2 100644
--- a/web_src/js/features/repo-issue.ts
+++ b/web_src/js/features/repo-issue.ts
@@ -421,13 +421,11 @@ export function initRepoPullRequestReview() {
   // The following part is only for diff views
   if (!$('.repository.pull.diff').length) return;
 
-  const $reviewBtn = $('.js-btn-review');
-  const $panel = $reviewBtn.parent().find('.review-box-panel');
-  const $closeBtn = $panel.find('.close');
-
-  if ($reviewBtn.length && $panel.length) {
-    const tippy = createTippy($reviewBtn[0], {
-      content: $panel[0],
+  const elReviewBtn = document.querySelector('.js-btn-review');
+  const elReviewPanel = document.querySelector('.review-box-panel.tippy-target');
+  if (elReviewBtn && elReviewPanel) {
+    const tippy = createTippy(elReviewBtn, {
+      content: elReviewPanel,
       theme: 'default',
       placement: 'bottom',
       trigger: 'click',
@@ -435,11 +433,7 @@ export function initRepoPullRequestReview() {
       interactive: true,
       hideOnClick: true,
     });
-
-    $closeBtn.on('click', (e) => {
-      e.preventDefault();
-      tippy.hide();
-    });
+    elReviewPanel.querySelector('.close').addEventListener('click', () => tippy.hide());
   }
 
   addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => {
diff --git a/web_src/js/features/repo-settings.ts b/web_src/js/features/repo-settings.ts
index b61ef9a153..7e890a43e0 100644
--- a/web_src/js/features/repo-settings.ts
+++ b/web_src/js/features/repo-settings.ts
@@ -3,6 +3,7 @@ import {minimatch} from 'minimatch';
 import {createMonaco} from './codeeditor.ts';
 import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
 import {POST} from '../modules/fetch.ts';
+import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
 import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
 
 const {appSubUrl, csrfToken} = window.config;
@@ -156,4 +157,6 @@ export function initRepoSettings() {
   initRepoSettingsSearchTeamBox();
   initRepoSettingsGitHook();
   initRepoSettingsBranchesDrag();
+
+  queryElems(document, '.avatar-file-with-cropper', initAvatarUploaderWithCropper);
 }
diff --git a/web_src/js/features/user-settings.ts b/web_src/js/features/user-settings.ts
index 6312a8b682..21d20e676f 100644
--- a/web_src/js/features/user-settings.ts
+++ b/web_src/js/features/user-settings.ts
@@ -1,17 +1,10 @@
-import {hideElem, showElem} from '../utils/dom.ts';
-import {initCompCropper} from './comp/Cropper.ts';
-
-function initUserSettingsAvatarCropper() {
-  const fileInput = document.querySelector<HTMLInputElement>('#new-avatar');
-  const container = document.querySelector<HTMLElement>('.user.settings.profile .cropper-panel');
-  const imageSource = container.querySelector<HTMLImageElement>('.cropper-source');
-  initCompCropper({container, fileInput, imageSource});
-}
+import {hideElem, queryElems, showElem} from '../utils/dom.ts';
+import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
 
 export function initUserSettings() {
   if (!document.querySelector('.user.settings.profile')) return;
 
-  initUserSettingsAvatarCropper();
+  queryElems(document, '.avatar-file-with-cropper', initAvatarUploaderWithCropper);
 
   const usernameInput = document.querySelector<HTMLInputElement>('#username');
   if (!usernameInput) return;
diff --git a/web_src/js/svg.test.ts b/web_src/js/svg.test.ts
index 7f3e0496ec..715b739a82 100644
--- a/web_src/js/svg.test.ts
+++ b/web_src/js/svg.test.ts
@@ -16,12 +16,11 @@ test('svgParseOuterInner', () => {
 
 test('SvgIcon', () => {
   const root = document.createElement('div');
-  createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base', className: 'extra'})}).mount(root);
+  createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base'})}).mount(root);
   const node = root.firstChild as Element;
   expect(node.nodeName).toEqual('svg');
   expect(node.getAttribute('width')).toEqual('24');
   expect(node.getAttribute('height')).toEqual('24');
   expect(node.classList.contains('octicon-link')).toBeTruthy();
   expect(node.classList.contains('base')).toBeTruthy();
-  expect(node.classList.contains('extra')).toBeTruthy();
 });
diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts
index b193afb255..8316cbcf85 100644
--- a/web_src/js/svg.ts
+++ b/web_src/js/svg.ts
@@ -201,7 +201,6 @@ export const SvgIcon = defineComponent({
   props: {
     name: {type: String as PropType<SvgName>, required: true},
     size: {type: Number, default: 16},
-    className: {type: String, default: ''},
     symbolId: {type: String},
   },
   render() {
@@ -216,15 +215,7 @@ export const SvgIcon = defineComponent({
     attrs[`^width`] = this.size;
     attrs[`^height`] = this.size;
 
-    // make the <SvgIcon class="foo" class-name="bar"> classes work together
-    const classes: Array<string> = [];
-    for (const cls of svgOuter.classList) {
-      classes.push(cls);
-    }
-    // TODO: drop the `className/class-name` prop in the future, only use "class" prop
-    if (this.className) {
-      classes.push(...this.className.split(/\s+/).filter(Boolean));
-    }
+    const classes = Array.from(svgOuter.classList);
     if (this.symbolId) {
       classes.push('tw-hidden', 'svg-symbol-container');
       svgInnerHtml = `<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${svgInnerHtml}</symbol>`;
diff --git a/web_src/svg/gitea-feishu.svg b/web_src/svg/gitea-feishu.svg
new file mode 100644
index 0000000000..57941978d1
--- /dev/null
+++ b/web_src/svg/gitea-feishu.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="7 7 26 26" width="20" height="20"><path d="M21.069 20.504l.063-.06.125-.122.085-.084.256-.254.348-.344.299-.296.281-.278.293-.289.269-.266.374-.37.218-.206.419-.359.404-.306.598-.386.617-.33.606-.265.348-.127.177-.058a14.78 14.78 0 0 0-2.793-5.603c-.252-.318-.639-.502-1.047-.502H12.221c-.196 0-.277.249-.119.364a31.49 31.49 0 0 1 8.943 10.162c.008-.007.016-.015.025-.023z" fill="#00d6b9"/><path d="M16.791 30c5.57 0 10.423-3.074 12.955-7.618.089-.159.175-.321.258-.484a6.12 6.12 0 0 1-.425.699c-.055.078-.111.155-.17.23a6.29 6.29 0 0 1-.225.274c-.062.07-.123.138-.188.206a5.61 5.61 0 0 1-.407.384 5.53 5.53 0 0 1-.24.195 7.12 7.12 0 0 1-.292.21c-.063.043-.126.084-.191.122s-.134.081-.204.119c-.14.078-.282.149-.428.215a5.53 5.53 0 0 1-.385.157 5.81 5.81 0 0 1-.43.138 5.91 5.91 0 0 1-.661.143c-.162.025-.325.044-.491.055-.173.012-.348.016-.525.014-.193-.003-.388-.015-.585-.037-.144-.015-.289-.037-.433-.062-.126-.022-.252-.049-.38-.079l-.2-.051-.555-.155-.275-.081-.41-.125-.334-.107-.317-.104-.215-.073-.26-.091-.186-.066-.367-.134-.212-.081-.284-.11-.299-.119-.193-.079-.24-.1-.185-.078-.192-.084-.166-.073-.152-.067-.153-.07-.159-.073-.2-.093-.208-.099-.222-.108-.189-.093c-3.335-1.668-6.295-3.89-8.822-6.583-.126-.134-.349-.045-.349.138l.005 9.52v.773c0 .448.222.87.595 1.118C10.946 29.092 13.762 30 16.791 30z" fill="#3370ff"/><path d="M29.746 22.382h0l.051-.093-.051.093zm.231-.435l.014-.025.007-.012-.021.037z" fill="#133c92"/><path d="M33.151 16.582c-1.129-.556-2.399-.869-3.744-.869a8.45 8.45 0 0 0-2.303.317l-.252.075-.177.058-.348.127-.606.265-.617.33-.598.386-.404.306-.419.359-.218.206-.374.37-.269.266-.293.289-.281.278-.299.296-.348.344-.256.254-.085.084-.125.122-.063.06-.095.09-.105.099c-.924.848-1.956 1.581-3.072 2.175l.2.093.159.073.153.07.152.067.166.073.192.084.185.078.24.1.193.079.299.119.284.11.212.081.367.134.186.066.26.09.215.073.317.104.334.107.41.125.275.081.555.155.2.051.379.079.433.062.585.037.525-.014.491-.055a5.61 5.61 0 0 0 .66-.143l.43-.138.385-.158.427-.215.204-.119.191-.122.292-.21.24-.195.407-.384.188-.206.225-.274.17-.23a6.13 6.13 0 0 0 .421-.693l.144-.288 1.305-2.599-.003.006a8.07 8.07 0 0 1 1.697-2.439z" fill="#133c9a"/></svg>