diff options
31 files changed, 771 insertions, 121 deletions
diff --git a/models/actions/run.go b/models/actions/run.go index c19fce67ae..498a73dc20 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -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 diff --git a/models/actions/run_job.go b/models/actions/run_job.go index d0dfd10db6..c0df19b020 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -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 } diff --git a/models/actions/task_list.go b/models/actions/task_list.go index df4b43c5ef..0c80397899 100644 --- a/models/actions/task_list.go +++ b/models/actions/task_list.go @@ -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}) } diff --git a/models/activities/action.go b/models/activities/action.go index c89ba3e14e..6f1837d9f6 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -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) } diff --git a/models/fixtures/action_artifact.yml b/models/fixtures/action_artifact.yml index 1b00daf198..ee8ef0d5ce 100644 --- a/models/fixtures/action_artifact.yml +++ b/models/fixtures/action_artifact.yml @@ -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 diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml index 1db849352f..ae7dc481ec 100644 --- a/models/fixtures/action_run.yml +++ b/models/fixtures/action_run.yml @@ -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 diff --git a/models/fixtures/action_run_job.yml b/models/fixtures/action_run_job.yml index 8837e6ec2d..72f8627224 100644 --- a/models/fixtures/action_run_job.yml +++ b/models/fixtures/action_run_job.yml @@ -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 diff --git a/models/fixtures/action_task.yml b/models/fixtures/action_task.yml index 506a47d8a0..76fdac343b 100644 --- a/models/fixtures/action_task.yml +++ b/models/fixtures/action_task.yml @@ -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 diff --git a/models/organization/org_test.go b/models/organization/org_test.go index 666a6c44d4..234325a8cd 100644 --- a/models/organization/org_test.go +++ b/models/organization/org_test.go @@ -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}) diff --git a/models/repo/org_repo.go b/models/repo/org_repo.go index fa519d25b1..96f21ba2ac 100644 --- a/models/repo/org_repo.go +++ b/models/repo/org_repo.go @@ -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). diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index af3b948a88..b6411f7777 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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. diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini index cdde7e015d..2c38ad83bf 100644 --- a/options/locale/locale_ga-IE.ini +++ b/options/locale/locale_ga-IE.ini @@ -3728,7 +3728,11 @@ creation.name_placeholder=carachtair alfanumair nó íoslaghda amháin nach féi creation.value_placeholder=Ionchur ábhar ar bith. Fágfar spás bán ag tús agus ag deireadh ar lár. creation.description_placeholder=Cuir isteach cur síos gairid (roghnach). +save_success=Tá an rún "%s" sábháilte. +save_failed=Theip ar an rún a shábháil. +add_secret=Cuir rún leis +edit_secret=Cuir rún in eagar deletion=Bain rún deletion.description=Is buan rún a bhaint agus ní féidir é a chealú. Lean ort? deletion.success=Tá an rún bainte. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index b98863b418..95512cb9b6 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 6aef529f98..237250b2c5 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -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 diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 5014ff52e3..f466a184c3 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -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. diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index dd18c8380d..9840465bff 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -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. diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go index 5d963eff66..5d382ebd71 100644 --- a/routers/web/repo/branch.go +++ b/routers/web/repo/branch.go @@ -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")) diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index 3b053821ee..48fa47d738 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -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 { diff --git a/routers/web/web.go b/routers/web/web.go index bd850baec0..866401252d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/actions/cleanup.go b/services/actions/cleanup.go index 23d6e3a49d..5595649517 100644 --- a/services/actions/cleanup.go +++ b/services/actions/cleanup.go @@ -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 +} diff --git a/services/context/api.go b/services/context/api.go index d43e15bf24..28f0e43d88 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -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, }) diff --git a/services/context/context.go b/services/context/context.go index 7f623f85bd..32ec260aab 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -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"}) +} diff --git a/services/context/repo.go b/services/context/repo.go index 127d313258..ea772c508d 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -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 { diff --git a/services/org/user.go b/services/org/user.go index 3565ecc2fc..26927253d2 100644 --- a/services/org/user.go +++ b/services/org/user.go @@ -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 { diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index fa1adb3e3b..4ebedcd73b 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -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}} diff --git a/templates/shared/secrets/add_list.tmpl b/templates/shared/secrets/add_list.tmpl index a4ef2e5384..44305e9502 100644 --- a/templates/shared/secrets/add_list.tmpl +++ b/templates/shared/secrets/add_list.tmpl @@ -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"}}" diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 223a2e8410..e28fecaec0 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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": [ diff --git a/tests/integration/actions_delete_run_test.go b/tests/integration/actions_delete_run_test.go new file mode 100644 index 0000000000..22f9a1f740 --- /dev/null +++ b/tests/integration/actions_delete_run_test.go @@ -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) + } + }) +} diff --git a/tests/integration/admin_user_test.go b/tests/integration/admin_user_test.go index d5d7e70bc7..95e03ab750 100644 --- a/tests/integration/admin_user_test.go +++ b/tests/integration/admin_user_test.go @@ -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) - - assertUserDeleted(t, 8) - unittest.CheckConsistencyFor(t, &user_model.User{}) + usersToDelete := []struct { + userID int64 + purge bool + }{ + { + userID: 2, + purge: true, + }, + { + userID: 8, + }, + } + + 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{}) + }) + } } diff --git a/tests/integration/api_actions_delete_run_test.go b/tests/integration/api_actions_delete_run_test.go new file mode 100644 index 0000000000..5b41702c57 --- /dev/null +++ b/tests/integration/api_actions_delete_run_test.go @@ -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) +} diff --git a/tests/integration/empty_repo_test.go b/tests/integration/empty_repo_test.go index f85d883cc7..8cebfaf32a 100644 --- a/tests/integration/empty_repo_test.go +++ b/tests/integration/empty_repo_test.go @@ -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) - - 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") + 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.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) { |