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/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/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 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..3d9e4c9d79 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.HTTPError(http.StatusNotFound, err.Error()) + return + } + ctx.HTTPError(http.StatusInternalServerError, err.Error()) + return + } + + if !run.Status.IsDone() { + ctx.JSONError(ctx.Tr("actions.runs.not_done")) + return + } + + if err := actions_service.DeleteRun(ctx, run); err != nil { + ctx.HTTPError(http.StatusInternalServerError, err.Error()) + return + } + + ctx.JSON(http.StatusOK, struct{}{}) +} + // 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/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..1f15f84c82 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,84 @@ 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, j.ID != 0 + }) + 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: https://github.com/go-gitea/gitea/pull/34337#issuecomment-2862222788 + if err0 := CleanupEphemeralRunners(ctx); err0 != nil { + return err0 + } + 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/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index fa1adb3e3b..a920cf77f4 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -36,6 +36,15 @@
{{svg "octicon-calendar" 16}}{{DateUtils.TimeSince .Updated}}
{{svg "octicon-stopwatch" 16}}{{.Duration}}
+ {{if and ($.AllowDeleteWorkflowRuns) (.Link) (or (eq .Status.String "success") (eq .Status.String "skipped") (eq .Status.String "cancelled"))}} + + {{end}} {{end}} 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/api_actions_delete_run_test.go b/tests/integration/api_actions_delete_run_test.go new file mode 100644 index 0000000000..b6c2ad5ef3 --- /dev/null +++ b/tests/integration/api_actions_delete_run_test.go @@ -0,0 +1,88 @@ +// 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 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) +}