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

Merge branch 'go-gitea:main' into main

This commit is contained in:
badhezi 2025-05-13 22:52:08 +03:00 committed by GitHub
commit a3c29538f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 775 additions and 119 deletions

View File

@ -16,6 +16,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
@ -343,13 +344,13 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork
return committer.Commit()
}
func GetRunByID(ctx context.Context, id int64) (*ActionRun, error) {
func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, error) {
var run ActionRun
has, err := db.GetEngine(ctx).Where("id=?", id).Get(&run)
has, err := db.GetEngine(ctx).Where("id=? AND repo_id=?", runID, repoID).Get(&run)
if err != nil {
return nil, err
} else if !has {
return nil, fmt.Errorf("run with id %d: %w", id, util.ErrNotExist)
return nil, fmt.Errorf("run with id %d: %w", runID, util.ErrNotExist)
}
return &run, nil
@ -420,17 +421,10 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error {
if run.Status != 0 || slices.Contains(cols, "status") {
if run.RepoID == 0 {
run, err = GetRunByID(ctx, run.ID)
if err != nil {
return err
}
setting.PanicInDevOrTesting("RepoID should not be 0")
}
if run.Repo == nil {
repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
if err != nil {
return err
}
run.Repo = repo
if err = run.LoadRepo(ctx); err != nil {
return err
}
if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil {
return err

View File

@ -51,7 +51,7 @@ func (job *ActionRunJob) Duration() time.Duration {
func (job *ActionRunJob) LoadRun(ctx context.Context) error {
if job.Run == nil {
run, err := GetRunByID(ctx, job.RunID)
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
if err != nil {
return err
}
@ -142,7 +142,7 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
{
// Other goroutines may aggregate the status of the run and update it too.
// So we need load the run and its jobs before updating the run.
run, err := GetRunByID(ctx, job.RunID)
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
if err != nil {
return 0, err
}

View File

@ -48,6 +48,7 @@ func (tasks TaskList) LoadAttributes(ctx context.Context) error {
type FindTaskOptions struct {
db.ListOptions
RepoID int64
JobID int64
OwnerID int64
CommitSHA string
Status Status
@ -61,6 +62,9 @@ func (opts FindTaskOptions) ToConds() builder.Cond {
if opts.RepoID > 0 {
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
}
if opts.JobID > 0 {
cond = cond.And(builder.Eq{"job_id": opts.JobID})
}
if opts.OwnerID > 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
}

View File

@ -530,7 +530,7 @@ func ActivityQueryCondition(ctx context.Context, opts GetFeedsOptions) (builder.
if opts.RequestedTeam != nil {
env := repo_model.AccessibleTeamReposEnv(organization.OrgFromUser(opts.RequestedUser), opts.RequestedTeam)
teamRepoIDs, err := env.RepoIDs(ctx, 1, opts.RequestedUser.NumRepos)
teamRepoIDs, err := env.RepoIDs(ctx)
if err != nil {
return nil, fmt.Errorf("GetTeamRepositories: %w", err)
}

View File

@ -105,3 +105,39 @@
created_unix: 1730330775
updated_unix: 1730330775
expired_unix: 1738106775
-
id: 24
run_id: 795
runner_id: 1
repo_id: 2
owner_id: 2
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
storage_path: "27/5/1730330775594233150.chunk"
file_size: 1024
file_compressed_size: 1024
content_encoding: "application/zip"
artifact_path: "artifact-795-1.zip"
artifact_name: "artifact-795-1"
status: 2
created_unix: 1730330775
updated_unix: 1730330775
expired_unix: 1738106775
-
id: 25
run_id: 795
runner_id: 1
repo_id: 2
owner_id: 2
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
storage_path: "27/5/1730330775594233150.chunk"
file_size: 1024
file_compressed_size: 1024
content_encoding: "application/zip"
artifact_path: "artifact-795-2.zip"
artifact_name: "artifact-795-2"
status: 2
created_unix: 1730330775
updated_unix: 1730330775
expired_unix: 1738106775

View File

@ -48,7 +48,7 @@
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "push"
is_fork_pull_request: 0
status: 1
status: 6 # running
started: 1683636528
stopped: 1683636626
created: 1683636108
@ -74,3 +74,23 @@
updated: 1683636626
need_approval: 0
approved_by: 0
-
id: 795
title: "to be deleted (test)"
repo_id: 2
owner_id: 2
workflow_id: "test.yaml"
index: 191
trigger_user_id: 1
ref: "refs/heads/test"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "push"
is_fork_pull_request: 0
status: 2
started: 1683636528
stopped: 1683636626
created: 1683636108
updated: 1683636626
need_approval: 0
approved_by: 0

View File

@ -69,3 +69,33 @@
status: 5
started: 1683636528
stopped: 1683636626
-
id: 198
run_id: 795
repo_id: 2
owner_id: 2
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
name: job_1
attempt: 1
job_id: job_1
task_id: 53
status: 1
started: 1683636528
stopped: 1683636626
-
id: 199
run_id: 795
repo_id: 2
owner_id: 2
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
name: job_2
attempt: 1
job_id: job_2
task_id: 54
status: 2
started: 1683636528
stopped: 1683636626

View File

@ -117,3 +117,43 @@
log_length: 707
log_size: 90179
log_expired: 0
-
id: 53
job_id: 198
attempt: 1
runner_id: 1
status: 1
started: 1683636528
stopped: 1683636626
repo_id: 2
owner_id: 2
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
token_hash: b8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc4784223
token_salt: ffffffffff
token_last_eight: ffffffff
log_filename: artifact-test2/2f/47.log
log_in_storage: 1
log_length: 0
log_size: 0
log_expired: 0
-
id: 54
job_id: 199
attempt: 1
runner_id: 1
status: 2
started: 1683636528
stopped: 1683636626
repo_id: 2
owner_id: 2
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
token_hash: b8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc4784224
token_salt: ffffffffff
token_last_eight: ffffffff
log_filename: artifact-test2/2f/47.log
log_in_storage: 1
log_length: 0
log_size: 0
log_expired: 0

View File

@ -334,7 +334,7 @@ func TestAccessibleReposEnv_RepoIDs(t *testing.T) {
testSuccess := func(userID int64, expectedRepoIDs []int64) {
env, err := repo_model.AccessibleReposEnv(db.DefaultContext, org, userID)
assert.NoError(t, err)
repoIDs, err := env.RepoIDs(db.DefaultContext, 1, 100)
repoIDs, err := env.RepoIDs(db.DefaultContext)
assert.NoError(t, err)
assert.Equal(t, expectedRepoIDs, repoIDs)
}
@ -342,25 +342,6 @@ func TestAccessibleReposEnv_RepoIDs(t *testing.T) {
testSuccess(4, []int64{3, 32})
}
func TestAccessibleReposEnv_Repos(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
testSuccess := func(userID int64, expectedRepoIDs []int64) {
env, err := repo_model.AccessibleReposEnv(db.DefaultContext, org, userID)
assert.NoError(t, err)
repos, err := env.Repos(db.DefaultContext, 1, 100)
assert.NoError(t, err)
expectedRepos := make(repo_model.RepositoryList, len(expectedRepoIDs))
for i, repoID := range expectedRepoIDs {
expectedRepos[i] = unittest.AssertExistsAndLoadBean(t,
&repo_model.Repository{ID: repoID})
}
assert.Equal(t, expectedRepos, repos)
}
testSuccess(2, []int64{3, 5, 32})
testSuccess(4, []int64{3, 32})
}
func TestAccessibleReposEnv_MirrorRepos(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})

View File

@ -48,8 +48,7 @@ func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (Repo
// accessible to a particular user
type AccessibleReposEnvironment interface {
CountRepos(ctx context.Context) (int64, error)
RepoIDs(ctx context.Context, page, pageSize int) ([]int64, error)
Repos(ctx context.Context, page, pageSize int) (RepositoryList, error)
RepoIDs(ctx context.Context) ([]int64, error)
MirrorRepos(ctx context.Context) (RepositoryList, error)
AddKeyword(keyword string)
SetSort(db.SearchOrderBy)
@ -132,40 +131,18 @@ func (env *accessibleReposEnv) CountRepos(ctx context.Context) (int64, error) {
return repoCount, nil
}
func (env *accessibleReposEnv) RepoIDs(ctx context.Context, page, pageSize int) ([]int64, error) {
if page <= 0 {
page = 1
}
repoIDs := make([]int64, 0, pageSize)
func (env *accessibleReposEnv) RepoIDs(ctx context.Context) ([]int64, error) {
var repoIDs []int64
return repoIDs, db.GetEngine(ctx).
Table("repository").
Join("INNER", "team_repo", "`team_repo`.repo_id=`repository`.id").
Where(env.cond()).
GroupBy("`repository`.id,`repository`."+strings.Fields(string(env.orderBy))[0]).
GroupBy("`repository`.id,`repository`." + strings.Fields(string(env.orderBy))[0]).
OrderBy(string(env.orderBy)).
Limit(pageSize, (page-1)*pageSize).
Cols("`repository`.id").
Find(&repoIDs)
}
func (env *accessibleReposEnv) Repos(ctx context.Context, page, pageSize int) (RepositoryList, error) {
repoIDs, err := env.RepoIDs(ctx, page, pageSize)
if err != nil {
return nil, fmt.Errorf("GetUserRepositoryIDs: %w", err)
}
repos := make([]*Repository, 0, len(repoIDs))
if len(repoIDs) == 0 {
return repos, nil
}
return repos, db.GetEngine(ctx).
In("`repository`.id", repoIDs).
OrderBy(string(env.orderBy)).
Find(&repos)
}
func (env *accessibleReposEnv) MirrorRepoIDs(ctx context.Context) ([]int64, error) {
repoIDs := make([]int64, 0, 10)
return repoIDs, db.GetEngine(ctx).

View File

@ -3811,6 +3811,9 @@ runs.no_workflows.documentation = For more information on Gitea Actions, see <a
runs.no_runs = The workflow has no runs yet.
runs.empty_commit_message = (empty commit message)
runs.expire_log_message = Logs have been purged because they were too old.
runs.delete = Delete workflow run
runs.delete.description = Are you sure you want to permanently delete this workflow run? This action cannot be undone.
runs.not_done = This workflow run is not done.
workflow.disable = Disable Workflow
workflow.disable_success = Workflow '%s' disabled successfully.

View File

@ -1279,7 +1279,10 @@ func Routes() *web.Router {
}, reqToken(), reqAdmin())
m.Group("/actions", func() {
m.Get("/tasks", repo.ListActionTasks)
m.Get("/runs/{run}/artifacts", repo.GetArtifactsOfRun)
m.Group("/runs/{run}", func() {
m.Get("/artifacts", repo.GetArtifactsOfRun)
m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun)
})
m.Get("/artifacts", repo.GetArtifacts)
m.Group("/artifacts/{artifact_id}", func() {
m.Get("", repo.GetArtifact)

View File

@ -1061,6 +1061,58 @@ func GetArtifactsOfRun(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, &res)
}
// DeleteActionRun Delete a workflow run
func DeleteActionRun(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/actions/runs/{run} repository deleteActionRun
// ---
// summary: Delete a workflow run
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: name of the owner
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repository
// type: string
// required: true
// - name: run
// in: path
// description: runid of the workflow run
// type: integer
// required: true
// responses:
// "204":
// description: "No Content"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
runID := ctx.PathParamInt64("run")
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err)
return
} else if err != nil {
ctx.APIErrorInternal(err)
return
}
if !run.Status.IsDone() {
ctx.APIError(http.StatusBadRequest, "this workflow run is not done")
return
}
if err := actions_service.DeleteRun(ctx, run); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// GetArtifacts Lists all artifacts for a repository.
func GetArtifacts(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/artifacts repository getArtifacts

View File

@ -317,6 +317,8 @@ func prepareWorkflowList(ctx *context.Context, workflows []Workflow) {
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(runs) > 0
ctx.Data["AllowDeleteWorkflowRuns"] = ctx.Repo.CanWrite(unit.TypeActions)
}
// loadIsRefDeleted loads the IsRefDeleted field for each run in the list.

View File

@ -577,6 +577,33 @@ func Approve(ctx *context_module.Context) {
ctx.JSON(http.StatusOK, struct{}{})
}
func Delete(ctx *context_module.Context) {
runIndex := getRunIndex(ctx)
repoID := ctx.Repo.Repository.ID
run, err := actions_model.GetRunByIndex(ctx, repoID, runIndex)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.JSONErrorNotFound()
return
}
ctx.ServerError("GetRunByIndex", err)
return
}
if !run.Status.IsDone() {
ctx.JSONError(ctx.Tr("actions.runs.not_done"))
return
}
if err := actions_service.DeleteRun(ctx, run); err != nil {
ctx.ServerError("DeleteRun", err)
return
}
ctx.JSONOK()
}
// getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs.
// Any error will be written to the ctx.
// It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0.

View File

@ -264,7 +264,7 @@ func MergeUpstream(ctx *context.Context) {
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.JSONError(ctx.Tr("error.not_found"))
ctx.JSONErrorNotFound()
return
} else if pull_service.IsErrMergeConflicts(err) {
ctx.JSONError(ctx.Tr("repo.pulls.merge_conflict"))

View File

@ -21,6 +21,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
giturl "code.gitea.io/gitea/modules/git/url"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository"
@ -261,6 +262,10 @@ func updateContextRepoEmptyAndStatus(ctx *context.Context, empty bool, status re
func handleRepoEmptyOrBroken(ctx *context.Context) {
showEmpty := true
if ctx.Repo.GitRepo == nil {
// in case the repo really exists and works, but the status was incorrectly marked as "broken", we need to open and check it again
ctx.Repo.GitRepo, _ = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository)
}
if ctx.Repo.GitRepo != nil {
reallyEmpty, err := ctx.Repo.GitRepo.IsEmpty()
if err != nil {
@ -396,10 +401,8 @@ func Home(ctx *context.Context) {
return
}
prepareHomeTreeSideBarSwitch(ctx)
title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name
if len(ctx.Repo.Repository.Description) > 0 {
if ctx.Repo.Repository.Description != "" {
title += ": " + ctx.Repo.Repository.Description
}
ctx.Data["Title"] = title
@ -412,6 +415,8 @@ func Home(ctx *context.Context) {
return
}
prepareHomeTreeSideBarSwitch(ctx)
// get the current git entry which doer user is currently looking at.
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
if err != nil {

View File

@ -1447,6 +1447,7 @@ func registerWebRoutes(m *web.Router) {
})
m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
m.Post("/approve", reqRepoActionsWriter, actions.Approve)
m.Post("/delete", reqRepoActionsWriter, actions.Delete)
m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView)
m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)

View File

@ -5,12 +5,14 @@ package actions
import (
"context"
"errors"
"fmt"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
@ -27,7 +29,7 @@ func Cleanup(ctx context.Context) error {
}
// clean up old logs
if err := CleanupLogs(ctx); err != nil {
if err := CleanupExpiredLogs(ctx); err != nil {
return fmt.Errorf("cleanup logs: %w", err)
}
@ -98,8 +100,15 @@ func cleanNeedDeleteArtifacts(taskCtx context.Context) error {
const deleteLogBatchSize = 100
// CleanupLogs removes logs which are older than the configured retention time
func CleanupLogs(ctx context.Context) error {
func removeTaskLog(ctx context.Context, task *actions_model.ActionTask) {
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, go on
}
}
// CleanupExpiredLogs removes logs which are older than the configured retention time
func CleanupExpiredLogs(ctx context.Context) error {
olderThan := timeutil.TimeStampNow().AddDuration(-time.Duration(setting.Actions.LogRetentionDays) * 24 * time.Hour)
count := 0
@ -109,10 +118,7 @@ func CleanupLogs(ctx context.Context) error {
return fmt.Errorf("find old tasks: %w", err)
}
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, go on
}
removeTaskLog(ctx, task)
task.LogIndexes = nil // clear log indexes since it's a heavy field
task.LogExpired = true
if err := actions_model.UpdateTask(ctx, task, "log_indexes", "log_expired"); err != nil {
@ -148,3 +154,91 @@ func CleanupEphemeralRunners(ctx context.Context) error {
log.Info("Removed %d runners", affected)
return nil
}
// DeleteRun deletes workflow run, including all logs and artifacts.
func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
if !run.Status.IsDone() {
return errors.New("run is not done")
}
repoID := run.RepoID
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
if err != nil {
return err
}
jobIDs := container.FilterSlice(jobs, func(j *actions_model.ActionRunJob) (int64, bool) {
return j.ID, true
})
tasks := make(actions_model.TaskList, 0)
if len(jobIDs) > 0 {
if err := db.GetEngine(ctx).Where("repo_id = ?", repoID).In("job_id", jobIDs).Find(&tasks); err != nil {
return err
}
}
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
RepoID: repoID,
RunID: run.ID,
})
if err != nil {
return err
}
var recordsToDelete []any
recordsToDelete = append(recordsToDelete, &actions_model.ActionRun{
RepoID: repoID,
ID: run.ID,
})
recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJob{
RepoID: repoID,
RunID: run.ID,
})
for _, tas := range tasks {
recordsToDelete = append(recordsToDelete, &actions_model.ActionTask{
RepoID: repoID,
ID: tas.ID,
})
recordsToDelete = append(recordsToDelete, &actions_model.ActionTaskStep{
RepoID: repoID,
TaskID: tas.ID,
})
recordsToDelete = append(recordsToDelete, &actions_model.ActionTaskOutput{
TaskID: tas.ID,
})
}
recordsToDelete = append(recordsToDelete, &actions_model.ActionArtifact{
RepoID: repoID,
RunID: run.ID,
})
if err := db.WithTx(ctx, func(ctx context.Context) error {
// TODO: Deleting task records could break current ephemeral runner implementation. This is a temporary workaround suggested by ChristopherHX.
// Since you delete potentially the only task an ephemeral act_runner has ever run, please delete the affected runners first.
// one of
// call cleanup ephemeral runners first
// delete affected ephemeral act_runners
// I would make ephemeral runners fully delete directly before formally finishing the task
//
// See also: https://github.com/go-gitea/gitea/pull/34337#issuecomment-2862222788
if err := CleanupEphemeralRunners(ctx); err != nil {
return err
}
return db.DeleteBeans(ctx, recordsToDelete...)
}); err != nil {
return err
}
// Delete files on storage
for _, tas := range tasks {
removeTaskLog(ctx, tas)
}
for _, art := range artifacts {
if err := storage.ActionsArtifacts.Delete(art.StoragePath); err != nil {
log.Error("remove artifact file %q: %v", art.StoragePath, err)
}
}
return nil
}

View File

@ -245,7 +245,7 @@ func APIContexter() func(http.Handler) http.Handler {
// APIErrorNotFound handles 404s for APIContext
// String will replace message, errors will be added to a slice
func (ctx *APIContext) APIErrorNotFound(objs ...any) {
message := ctx.Locale.TrString("error.not_found")
var message string
var errs []string
for _, obj := range objs {
// Ignore nil
@ -259,9 +259,8 @@ func (ctx *APIContext) APIErrorNotFound(objs ...any) {
message = obj.(string)
}
}
ctx.JSON(http.StatusNotFound, map[string]any{
"message": message,
"message": util.IfZero(message, "not found"), // do not use locale in API
"url": setting.API.SwaggerURL,
"errors": errs,
})

View File

@ -23,6 +23,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/modules/web/middleware"
web_types "code.gitea.io/gitea/modules/web/types"
@ -261,3 +262,11 @@ func (ctx *Context) JSONError(msg any) {
panic(fmt.Sprintf("unsupported type: %T", msg))
}
}
func (ctx *Context) JSONErrorNotFound(optMsg ...string) {
msg := util.OptionalArg(optMsg)
if msg == "" {
msg = ctx.Locale.TrString("error.not_found")
}
ctx.JSON(http.StatusNotFound, map[string]any{"errorMessage": msg, "renderFormat": "text"})
}

View File

@ -795,8 +795,8 @@ func RepoRefByType(detectRefType git.RefType) func(*Context) {
return func(ctx *Context) {
var err error
refType := detectRefType
if ctx.Repo.Repository.IsBeingCreated() {
return // no git repo, so do nothing, users will see a "migrating" UI provided by "migrate/migrating.tmpl"
if ctx.Repo.Repository.IsBeingCreated() || ctx.Repo.Repository.IsBroken() {
return // no git repo, so do nothing, users will see a "migrating" UI provided by "migrate/migrating.tmpl", or empty repo guide
}
// Empty repository does not have reference information.
if ctx.Repo.Repository.IsEmpty {

View File

@ -64,10 +64,11 @@ func RemoveOrgUser(ctx context.Context, org *organization.Organization, user *us
if err != nil {
return fmt.Errorf("AccessibleReposEnv: %w", err)
}
repoIDs, err := env.RepoIDs(ctx, 1, org.NumRepos)
repoIDs, err := env.RepoIDs(ctx)
if err != nil {
return fmt.Errorf("GetUserRepositories [%d]: %w", user.ID, err)
}
for _, repoID := range repoIDs {
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
if err != nil {

View File

@ -5,37 +5,46 @@
<h2>{{if $.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}</h2>
</div>
{{end}}
{{range .Runs}}
{{range $run := .Runs}}
<div class="flex-item tw-items-center">
<div class="flex-item-leading">
{{template "repo/actions/status" (dict "status" .Status.String)}}
{{template "repo/actions/status" (dict "status" $run.Status.String)}}
</div>
<div class="flex-item-main">
<a class="flex-item-title" title="{{.Title}}" href="{{if .Link}}{{.Link}}{{else}}{{$.Link}}/{{.Index}}{{end}}">
{{if .Title}}{{.Title}}{{else}}{{ctx.Locale.Tr "actions.runs.empty_commit_message"}}{{end}}
<a class="flex-item-title" title="{{$run.Title}}" href="{{$run.Link}}">
{{or $run.Title (ctx.Locale.Tr "actions.runs.empty_commit_message")}}
</a>
<div class="flex-item-body">
<span><b>{{if not $.CurWorkflow}}{{.WorkflowID}} {{end}}#{{.Index}}</b>:</span>
{{- if .ScheduleID -}}
<span><b>{{if not $.CurWorkflow}}{{$run.WorkflowID}} {{end}}#{{$run.Index}}</b>:</span>
{{- if $run.ScheduleID -}}
{{ctx.Locale.Tr "actions.runs.scheduled"}}
{{- else -}}
{{ctx.Locale.Tr "actions.runs.commit"}}
<a href="{{$.RepoLink}}/commit/{{.CommitSHA}}">{{ShortSha .CommitSHA}}</a>
<a href="{{$.RepoLink}}/commit/{{$run.CommitSHA}}">{{ShortSha $run.CommitSHA}}</a>
{{ctx.Locale.Tr "actions.runs.pushed_by"}}
<a href="{{.TriggerUser.HomeLink}}">{{.TriggerUser.GetDisplayName}}</a>
<a href="{{$run.TriggerUser.HomeLink}}">{{$run.TriggerUser.GetDisplayName}}</a>
{{- end -}}
</div>
</div>
<div class="flex-item-trailing">
{{if .IsRefDeleted}}
<span class="ui label run-list-ref gt-ellipsis tw-line-through" data-tooltip-content="{{.PrettyRef}}">{{.PrettyRef}}</span>
{{if $run.IsRefDeleted}}
<span class="ui label run-list-ref gt-ellipsis tw-line-through" data-tooltip-content="{{$run.PrettyRef}}">{{$run.PrettyRef}}</span>
{{else}}
<a class="ui label run-list-ref gt-ellipsis" href="{{.RefLink}}" data-tooltip-content="{{.PrettyRef}}">{{.PrettyRef}}</a>
<a class="ui label run-list-ref gt-ellipsis" href="{{$run.RefLink}}" data-tooltip-content="{{$run.PrettyRef}}">{{$run.PrettyRef}}</a>
{{end}}
<div class="run-list-item-right">
<div class="run-list-meta">{{svg "octicon-calendar" 16}}{{DateUtils.TimeSince .Updated}}</div>
<div class="run-list-meta">{{svg "octicon-stopwatch" 16}}{{.Duration}}</div>
<div class="run-list-meta">{{svg "octicon-calendar" 16}}{{DateUtils.TimeSince $run.Updated}}</div>
<div class="run-list-meta">{{svg "octicon-stopwatch" 16}}{{$run.Duration}}</div>
</div>
{{if and ($.AllowDeleteWorkflowRuns) ($run.Status.IsDone)}}
<button class="btn interact-bg link-action tw-p-2"
data-url="{{$run.Link}}/delete"
data-modal-confirm="{{ctx.Locale.Tr "actions.runs.delete.description"}}"
data-tooltip-content="{{ctx.Locale.Tr "actions.runs.delete"}}"
>
{{svg "octicon-trash"}}
</button>
{{end}}
</div>
</div>
{{end}}

View File

@ -4,7 +4,7 @@
<div class="flex-item">
<div class="flex-item-main">
<div class="flex-item-title">
<a class="item muted" href="{{.Link}}/releases">
<a class="item muted" href="{{.RepoLink}}/releases">
{{ctx.Locale.Tr "repo.releases"}}
<span class="ui small label">{{.NumReleases}}</span>
</a>

View File

@ -37,7 +37,7 @@
<span class="color-text-light-2">
{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}
</span>
<button class="ui btn interact-bg show-modal tw-p-2"
<button class="btn interact-bg show-modal tw-p-2"
data-modal="#add-secret-modal"
data-modal-form.action="{{$.Link}}"
data-modal-header="{{ctx.Locale.Tr "secrets.edit_secret"}}"
@ -49,7 +49,7 @@
>
{{svg "octicon-pencil"}}
</button>
<button class="ui btn interact-bg link-action tw-p-2"
<button class="btn interact-bg link-action tw-p-2"
data-url="{{$.Link}}/delete?id={{.ID}}"
data-modal-confirm="{{ctx.Locale.Tr "secrets.deletion.description"}}"
data-tooltip-content="{{ctx.Locale.Tr "secrets.deletion"}}"

View File

@ -4758,6 +4758,52 @@
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run}": {
"delete": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Delete a workflow run",
"operationId": "deleteActionRun",
"parameters": [
{
"type": "string",
"description": "name of the owner",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "runid of the workflow run",
"name": "run",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"400": {
"$ref": "#/responses/error"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/artifacts": {
"get": {
"produces": [

View File

@ -0,0 +1,181 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
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/json"
"code.gitea.io/gitea/routers/web/repo/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestActionsDeleteRun(t *testing.T) {
now := time.Now()
testCase := struct {
treePath string
fileContent string
outcomes map[string]*mockTaskOutcome
expectedStatuses map[string]string
}{
treePath: ".gitea/workflows/test1.yml",
fileContent: `name: test1
on:
push:
paths:
- .gitea/workflows/test1.yml
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
steps:
- run: echo job2
job3:
runs-on: ubuntu-latest
steps:
- run: echo job3
`,
outcomes: map[string]*mockTaskOutcome{
"job1": {
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(4 * time.Second)),
Content: " \U0001F433 docker create image",
},
{
Time: timestamppb.New(now.Add(5 * time.Second)),
Content: "job1",
},
{
Time: timestamppb.New(now.Add(6 * time.Second)),
Content: "\U0001F3C1 Job succeeded",
},
},
},
"job2": {
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(4 * time.Second)),
Content: " \U0001F433 docker create image",
},
{
Time: timestamppb.New(now.Add(5 * time.Second)),
Content: "job2",
},
{
Time: timestamppb.New(now.Add(6 * time.Second)),
Content: "\U0001F3C1 Job succeeded",
},
},
},
"job3": {
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(4 * time.Second)),
Content: " \U0001F433 docker create image",
},
{
Time: timestamppb.New(now.Add(5 * time.Second)),
Content: "job3",
},
{
Time: timestamppb.New(now.Add(6 * time.Second)),
Content: "\U0001F3C1 Job succeeded",
},
},
},
},
expectedStatuses: map[string]string{
"job1": actions_model.StatusSuccess.String(),
"job2": actions_model.StatusSuccess.String(),
"job3": actions_model.StatusSuccess.String(),
},
}
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, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-delete-run-test", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
opts := getWorkflowCreateFileOptions(user2, apiRepo.DefaultBranch, "create "+testCase.treePath, testCase.fileContent)
createWorkflowFile(t, token, user2.Name, apiRepo.Name, testCase.treePath, opts)
runIndex := ""
for i := 0; i < len(testCase.outcomes); i++ {
task := runner.fetchTask(t)
jobName := getTaskJobNameByTaskID(t, token, user2.Name, apiRepo.Name, task.Id)
outcome := testCase.outcomes[jobName]
assert.NotNil(t, outcome)
runner.execTask(t, task, outcome)
runIndex = task.Context.GetFields()["run_number"].GetStringValue()
assert.Equal(t, "1", runIndex)
}
for i := 0; i < len(testCase.outcomes); i++ {
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%s/jobs/%d", user2.Name, apiRepo.Name, runIndex, i), map[string]string{
"_csrf": GetUserCSRFToken(t, session),
})
resp := session.MakeRequest(t, req, http.StatusOK)
var listResp actions.ViewResponse
err := json.Unmarshal(resp.Body.Bytes(), &listResp)
assert.NoError(t, err)
assert.Len(t, listResp.State.Run.Jobs, 3)
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%s/jobs/%d/logs", user2.Name, apiRepo.Name, runIndex, i)).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
req := NewRequestWithValues(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%s", user2.Name, apiRepo.Name, runIndex), map[string]string{
"_csrf": GetUserCSRFToken(t, session),
})
session.MakeRequest(t, req, http.StatusOK)
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%s/delete", user2.Name, apiRepo.Name, runIndex), map[string]string{
"_csrf": GetUserCSRFToken(t, session),
})
session.MakeRequest(t, req, http.StatusOK)
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%s/delete", user2.Name, apiRepo.Name, runIndex), map[string]string{
"_csrf": GetUserCSRFToken(t, session),
})
session.MakeRequest(t, req, http.StatusNotFound)
req = NewRequestWithValues(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%s", user2.Name, apiRepo.Name, runIndex), map[string]string{
"_csrf": GetUserCSRFToken(t, session),
})
session.MakeRequest(t, req, http.StatusNotFound)
for i := 0; i < len(testCase.outcomes); i++ {
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%s/jobs/%d", user2.Name, apiRepo.Name, runIndex, i), map[string]string{
"_csrf": GetUserCSRFToken(t, session),
})
session.MakeRequest(t, req, http.StatusNotFound)
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%s/jobs/%d/logs", user2.Name, apiRepo.Name, runIndex, i)).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
})
}

View File

@ -4,6 +4,7 @@
package integration
import (
"fmt"
"net/http"
"strconv"
"testing"
@ -72,12 +73,37 @@ func TestAdminDeleteUser(t *testing.T) {
session := loginUser(t, "user1")
csrf := GetUserCSRFToken(t, session)
req := NewRequestWithValues(t, "POST", "/-/admin/users/8/delete", map[string]string{
"_csrf": csrf,
})
session.MakeRequest(t, req, http.StatusSeeOther)
usersToDelete := []struct {
userID int64
purge bool
}{
{
userID: 2,
purge: true,
},
{
userID: 8,
},
}
assertUserDeleted(t, 8)
unittest.CheckConsistencyFor(t, &user_model.User{})
for _, entry := range usersToDelete {
t.Run(fmt.Sprintf("DeleteUser%d", entry.userID), func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: entry.userID})
assert.NotNil(t, user)
var query string
if entry.purge {
query = "?purge=true"
}
csrf := GetUserCSRFToken(t, session)
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/-/admin/users/%d/delete%s", entry.userID, query), map[string]string{
"_csrf": csrf,
})
session.MakeRequest(t, req, http.StatusSeeOther)
assertUserDeleted(t, entry.userID)
unittest.CheckConsistencyFor(t, &user_model.User{})
})
}
}

View File

@ -0,0 +1,98 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
api "code.gitea.io/gitea/modules/structs"
"github.com/stretchr/testify/assert"
)
func TestAPIActionsDeleteRunCheckPermission(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound)
}
func TestAPIActionsDeleteRun(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
testAPIActionsDeleteRunListArtifacts(t, repo, token, 2)
testAPIActionsDeleteRunListTasks(t, repo, token, true)
testAPIActionsDeleteRun(t, repo, token, http.StatusNoContent)
testAPIActionsDeleteRunListArtifacts(t, repo, token, 0)
testAPIActionsDeleteRunListTasks(t, repo, token, false)
testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound)
}
func TestAPIActionsDeleteRunRunning(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793", repo.FullName())).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusBadRequest)
}
func testAPIActionsDeleteRun(t *testing.T, repo *repo_model.Repository, token string, expected int) {
req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795", repo.FullName())).
AddTokenAuth(token)
MakeRequest(t, req, expected)
}
func testAPIActionsDeleteRunListArtifacts(t *testing.T, repo *repo_model.Repository, token string, artifacts int) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/artifacts", repo.FullName())).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var listResp api.ActionArtifactsResponse
err := json.Unmarshal(resp.Body.Bytes(), &listResp)
assert.NoError(t, err)
assert.Len(t, listResp.Entries, artifacts)
}
func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository, token string, expected bool) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/tasks", repo.FullName())).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var listResp api.ActionTaskResponse
err := json.Unmarshal(resp.Body.Bytes(), &listResp)
assert.NoError(t, err)
findTask1 := false
findTask2 := false
for _, entry := range listResp.Entries {
if entry.ID == 53 {
findTask1 = true
continue
}
if entry.ID == 54 {
findTask2 = true
continue
}
}
assert.Equal(t, expected, findTask1)
assert.Equal(t, expected, findTask2)
}

View File

@ -23,6 +23,7 @@ import (
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
@ -100,22 +101,29 @@ func TestEmptyRepoAddFile(t *testing.T) {
assert.Contains(t, resp.Body.String(), "test-file.md")
// if the repo is in incorrect state, it should be able to self-heal (recover to correct state)
user30EmptyRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: 30, Name: "empty"})
user30EmptyRepo.IsEmpty = true
user30EmptyRepo.DefaultBranch = "no-such"
_, err := db.GetEngine(db.DefaultContext).ID(user30EmptyRepo.ID).Cols("is_empty", "default_branch").Update(user30EmptyRepo)
require.NoError(t, err)
user30EmptyRepo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: 30, Name: "empty"})
assert.True(t, user30EmptyRepo.IsEmpty)
testEmptyOrBrokenRecover := func(t *testing.T, isEmpty, isBroken bool) {
user30EmptyRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: 30, Name: "empty"})
user30EmptyRepo.IsEmpty = isEmpty
user30EmptyRepo.Status = util.Iif(isBroken, repo_model.RepositoryBroken, repo_model.RepositoryReady)
user30EmptyRepo.DefaultBranch = "no-such"
_, err := db.GetEngine(db.DefaultContext).ID(user30EmptyRepo.ID).Cols("is_empty", "status", "default_branch").Update(user30EmptyRepo)
require.NoError(t, err)
user30EmptyRepo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: 30, Name: "empty"})
assert.Equal(t, isEmpty, user30EmptyRepo.IsEmpty)
assert.Equal(t, isBroken, user30EmptyRepo.Status == repo_model.RepositoryBroken)
req = NewRequest(t, "GET", "/user30/empty")
resp = session.MakeRequest(t, req, http.StatusSeeOther)
redirect = test.RedirectURL(resp)
assert.Equal(t, "/user30/empty", redirect)
req = NewRequest(t, "GET", "/user30/empty")
resp = session.MakeRequest(t, req, http.StatusSeeOther)
redirect = test.RedirectURL(resp)
assert.Equal(t, "/user30/empty", redirect)
req = NewRequest(t, "GET", "/user30/empty")
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "test-file.md")
req = NewRequest(t, "GET", "/user30/empty")
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "test-file.md")
}
testEmptyOrBrokenRecover(t, true, false)
testEmptyOrBrokenRecover(t, false, true)
testEmptyOrBrokenRecover(t, true, true)
}
func TestEmptyRepoUploadFile(t *testing.T) {

View File

@ -161,6 +161,7 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom =
function resizeToFit() {
if (isUserResized) return;
if (textarea.offsetWidth <= 0 && textarea.offsetHeight <= 0) return;
const previousMargin = textarea.style.marginBottom;
try {
const {top, bottom} = overflowOffset();
@ -176,6 +177,9 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom =
const curHeight = parseFloat(computedStyle.height);
const maxHeight = curHeight + bottom - adjustedViewportMarginBottom;
// In Firefox, setting auto height momentarily may cause the page to scroll up
// unexpectedly, prevent this by setting a temporary margin.
textarea.style.marginBottom = `${textarea.clientHeight}px`;
textarea.style.height = 'auto';
let newHeight = textarea.scrollHeight + borderAddOn;
@ -196,6 +200,12 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom =
textarea.style.height = `${newHeight}px`;
lastStyleHeight = textarea.style.height;
} finally {
// restore previous margin
if (previousMargin) {
textarea.style.marginBottom = previousMargin;
} else {
textarea.style.removeProperty('margin-bottom');
}
// ensure that the textarea is fully scrolled to the end, when the cursor
// is at the end during an input event
if (textarea.selectionStart === textarea.selectionEnd &&