mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
feat: make it possible to remove workflow runs (#12478)
Add the ability to remove workflow runs, either using the UI or the HTTP API. Workflow runs can only be removed once a workflow run has completed. For security reasons, only a repository administrator or a token with `write:repository` permissions can remove runs. Resolves https://codeberg.org/forgejo/forgejo/issues/2184. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests for Go changes (can be removed for JavaScript changes) - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [x] `make pr-go` before pushing ### Tests for JavaScript changes (can be removed for Go changes) - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. *The decision if the pull request will be shown in the release notes is up to the mergers / release team.* The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead. <!--start release-notes-assistant--> ## Release notes <!--URL:https://codeberg.org/forgejo/forgejo--> - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/12478): <!--number 12478 --><!--line 0 --><!--description bWFrZSBpdCBwb3NzaWJsZSB0byByZW1vdmUgd29ya2Zsb3cgcnVucw==-->make it possible to remove workflow runs<!--description--> <!--end release-notes-assistant--> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12478 Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
This commit is contained in:
parent
2d5dd62cf3
commit
03312e4f46
60 changed files with 1221 additions and 6 deletions
|
|
@ -222,6 +222,15 @@ func SetArtifactDeleted(ctx context.Context, artifactID int64) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetArtifactsOfRunDeleted marks all artifacts of the given run as deleted.
|
||||||
|
func SetArtifactsOfRunDeleted(ctx context.Context, runID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).
|
||||||
|
Where("run_id=?", runID).
|
||||||
|
Cols("status").
|
||||||
|
Update(&ActionArtifact{Status: int64(ArtifactStatusPendingDeletion)})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// aggregatedArtifactConds returns the common WHERE clause used by aggregated
|
// aggregatedArtifactConds returns the common WHERE clause used by aggregated
|
||||||
// artifact queries: restrict to visible statuses and apply the caller's filters.
|
// artifact queries: restrict to visible statuses and apply the caller's filters.
|
||||||
// The Status field on opts is ignored — visibility is fixed to UploadConfirmed/Expired.
|
// The Status field on opts is ignored — visibility is fixed to UploadConfirmed/Expired.
|
||||||
|
|
|
||||||
|
|
@ -612,4 +612,11 @@ func ComputeRunStatus(ctx context.Context, runID int64) (run *ActionRun, columns
|
||||||
return run, columns, nil
|
return run, columns, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteRun removes the given run. It is the caller's responsibility to handle the run's dependencies like artifacts or
|
||||||
|
// jobs. Nothing happens if the run does not exist.
|
||||||
|
func DeleteRun(ctx context.Context, runID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Delete(&ActionRun{ID: runID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
type ActionRunIndex db.ResourceIndex
|
type ActionRunIndex db.ResourceIndex
|
||||||
|
|
|
||||||
|
|
@ -365,3 +365,10 @@ func (job *ActionRunJob) AllNeedsExist(allExistingJobIDs container.Set[string])
|
||||||
|
|
||||||
return unknownJobIDs, len(unknownJobIDs) == 0
|
return unknownJobIDs, len(unknownJobIDs) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteJob removes the given job. Removing all associated tasks is up to the caller. If the given job does not exist,
|
||||||
|
// nothing happens.
|
||||||
|
func DeleteJob(ctx context.Context, jobID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Delete(&ActionRunJob{ID: jobID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,13 @@ func FixRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) {
|
||||||
return res.RowsAffected()
|
return res.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteEphemeralRunner removes the ephemeral runner with the given ID. If the runner with the given ID is not an
|
||||||
|
// ephemeral runner, nothing happens.
|
||||||
|
func DeleteEphemeralRunner(ctx context.Context, id int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where(builder.Eq{"id": id, "ephemeral": true}).Delete(&ActionRunner{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func DeleteOfflineRunners(ctx context.Context, olderThan timeutil.TimeStamp, globalOnly bool) error {
|
func DeleteOfflineRunners(ctx context.Context, olderThan timeutil.TimeStamp, globalOnly bool) error {
|
||||||
log.Info("Doing: DeleteOfflineRunners")
|
log.Info("Doing: DeleteOfflineRunners")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -479,3 +479,62 @@ func TestRunner_FindRunnerOptionsToConds(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteEphemeralRunner(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
persistentRunnerOne := &ActionRunner{
|
||||||
|
ID: 606526,
|
||||||
|
UUID: "d53a1222-ae7a-4430-97f8-8fcb6efd04c9",
|
||||||
|
Name: "persistent-runner-one",
|
||||||
|
OwnerID: 2,
|
||||||
|
RepoID: 0,
|
||||||
|
Ephemeral: false,
|
||||||
|
TokenHash: "J9YDsQL",
|
||||||
|
}
|
||||||
|
persistentRunnerTwo := &ActionRunner{
|
||||||
|
ID: 606527,
|
||||||
|
UUID: "3dc23067-b2fd-4daf-b428-dddad80d7f37",
|
||||||
|
Name: "persistent-runner-two",
|
||||||
|
OwnerID: 2,
|
||||||
|
RepoID: 0,
|
||||||
|
Ephemeral: false,
|
||||||
|
TokenHash: "jvIylZtHsS",
|
||||||
|
}
|
||||||
|
ephemeralRunnerOne := &ActionRunner{
|
||||||
|
ID: 606528,
|
||||||
|
UUID: "2d9bc0a1-7019-4ed3-ba67-6415415ac2a9",
|
||||||
|
Name: "ephemeral-runner-one",
|
||||||
|
OwnerID: 2,
|
||||||
|
RepoID: 0,
|
||||||
|
Ephemeral: true,
|
||||||
|
TokenHash: "t9C8L0kM3W",
|
||||||
|
}
|
||||||
|
ephemeralRunnerTwo := &ActionRunner{
|
||||||
|
ID: 606529,
|
||||||
|
UUID: "da7a03f8-ab39-4c54-9ec9-2bd312fe3be1",
|
||||||
|
Name: "ephemeral-runner-two",
|
||||||
|
OwnerID: 2,
|
||||||
|
RepoID: 0,
|
||||||
|
Ephemeral: true,
|
||||||
|
TokenHash: "g9oTOFM",
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, CreateRunner(t.Context(), persistentRunnerOne))
|
||||||
|
require.NoError(t, CreateRunner(t.Context(), persistentRunnerTwo))
|
||||||
|
require.NoError(t, CreateRunner(t.Context(), ephemeralRunnerOne))
|
||||||
|
require.NoError(t, CreateRunner(t.Context(), ephemeralRunnerTwo))
|
||||||
|
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: persistentRunnerOne.ID})
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: persistentRunnerTwo.ID})
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: ephemeralRunnerOne.ID})
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: ephemeralRunnerTwo.ID})
|
||||||
|
|
||||||
|
require.NoError(t, DeleteEphemeralRunner(t.Context(), persistentRunnerOne.ID))
|
||||||
|
require.NoError(t, DeleteEphemeralRunner(t.Context(), ephemeralRunnerOne.ID))
|
||||||
|
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: persistentRunnerOne.ID})
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: persistentRunnerTwo.ID})
|
||||||
|
unittest.AssertNotExistsBean(t, &ActionRunner{ID: ephemeralRunnerOne.ID})
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: ephemeralRunnerTwo.ID})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,15 @@ func HasTaskForRunner(ctx context.Context, runnerID int64) (bool, error) {
|
||||||
return db.GetEngine(ctx).Where("runner_id = ?", runnerID).Exist(&ActionTask{})
|
return db.GetEngine(ctx).Where("runner_id = ?", runnerID).Exist(&ActionTask{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetTasksOfJob(ctx context.Context, jobID int64) ([]*ActionTask, error) {
|
||||||
|
var tasks []*ActionTask
|
||||||
|
err := db.GetEngine(ctx).Where("job_id=?", jobID).Find(&tasks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot fetch tasks of job %d: %w", jobID, err)
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetTaskByJobAttempt(ctx context.Context, jobID, attempt int64) (*ActionTask, error) {
|
func GetTaskByJobAttempt(ctx context.Context, jobID, attempt int64) (*ActionTask, error) {
|
||||||
var task ActionTask
|
var task ActionTask
|
||||||
has, err := db.GetEngine(ctx).Where("job_id=?", jobID).Where("attempt=?", attempt).Get(&task)
|
has, err := db.GetEngine(ctx).Where("job_id=?", jobID).Where("attempt=?", attempt).Get(&task)
|
||||||
|
|
@ -497,6 +506,27 @@ func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteTask removes the given task including all its steps and outputs. Removing logs and ephemeral runners is the
|
||||||
|
// caller's responsibility.
|
||||||
|
func DeleteTask(ctx context.Context, taskID int64) error {
|
||||||
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
|
_, err = db.GetEngine(ctx).Delete(&ActionTaskStep{TaskID: taskID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to delete steps of task %d: %w", taskID, err)
|
||||||
|
}
|
||||||
|
_, err = db.GetEngine(ctx).Delete(&ActionTaskOutput{TaskID: taskID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to delete outputs of task %d: %w", taskID, err)
|
||||||
|
}
|
||||||
|
_, err = db.GetEngine(ctx).Delete(&ActionTask{ID: taskID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to delete task %d: %w", taskID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func FindOldTasksToExpire(ctx context.Context, olderThan timeutil.TimeStamp, limit int) ([]*ActionTask, error) {
|
func FindOldTasksToExpire(ctx context.Context, olderThan timeutil.TimeStamp, limit int) ([]*ActionTask, error) {
|
||||||
e := db.GetEngine(ctx)
|
e := db.GetEngine(ctx)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -672,6 +672,11 @@
|
||||||
"actions.runs.no_results": "No results matched.",
|
"actions.runs.no_results": "No results matched.",
|
||||||
"actions.runs.no_workflows": "There are no workflows yet.",
|
"actions.runs.no_workflows": "There are no workflows yet.",
|
||||||
"actions.runs.all_runs_link": "all runs",
|
"actions.runs.all_runs_link": "all runs",
|
||||||
|
"actions.runs.delete.error_could_not_load_run": "Could not load run to delete.",
|
||||||
|
"actions.runs.delete.error_could_not_delete_run": "Could not delete run.",
|
||||||
|
"actions.runs.delete.button": "Delete run",
|
||||||
|
"actions.runs.delete.error": "Could not delete the workflow run.",
|
||||||
|
"actions.runs.delete.confirm_action": "Do you really want to delete this workflow run?",
|
||||||
"actions.workflow.job_parsing_error": "Unable to parse jobs in workflow: %v",
|
"actions.workflow.job_parsing_error": "Unable to parse jobs in workflow: %v",
|
||||||
"actions.workflow.event_detection_error": "Unable to parse supported events in workflow: %v",
|
"actions.workflow.event_detection_error": "Unable to parse supported events in workflow: %v",
|
||||||
"actions.workflow.persistent_incomplete_matrix": "Unable to evaluate `strategy.matrix` of job %[1]s due to a `needs` expression that was invalid. It may reference a job that is not in it's 'needs' list (%[2]s), or an output that doesn't exist on one of those jobs.",
|
"actions.workflow.persistent_incomplete_matrix": "Unable to evaluate `strategy.matrix` of job %[1]s due to a `needs` expression that was invalid. It may reference a job that is not in it's 'needs' list (%[2]s), or an output that doesn't exist on one of those jobs.",
|
||||||
|
|
|
||||||
|
|
@ -450,9 +450,16 @@ func reqSelfOrAdmin() func(ctx *context.APIContext) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqAdmin user should be an owner or a collaborator with admin write of a repository, or site admin
|
// reqAdmin user should be an owner or a collaborator with admin write of a repository, or site admin. If one or more
|
||||||
func reqAdmin() func(ctx *context.APIContext) {
|
// unitTypes are given, it also requires that at least one the respective unitTypes is enabled.
|
||||||
|
func reqAdmin(unitTypes ...unit.Type) func(ctx *context.APIContext) {
|
||||||
return func(ctx *context.APIContext) {
|
return func(ctx *context.APIContext) {
|
||||||
|
if len(unitTypes) > 0 && !slices.ContainsFunc(unitTypes, func(unitType unit.Type) bool {
|
||||||
|
return ctx.Repo.Repository.UnitEnabled(ctx, unitType)
|
||||||
|
}) {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
if !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() {
|
if !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() {
|
||||||
ctx.Error(http.StatusForbidden, "reqAdmin", "user should be an owner or a collaborator with admin write of a repository")
|
ctx.Error(http.StatusForbidden, "reqAdmin", "user should be an owner or a collaborator with admin write of a repository")
|
||||||
return
|
return
|
||||||
|
|
@ -1245,6 +1252,7 @@ func Routes() *web.Route {
|
||||||
m.Group("/runs", func() {
|
m.Group("/runs", func() {
|
||||||
m.Get("", repo.ListActionRuns)
|
m.Get("", repo.ListActionRuns)
|
||||||
m.Get("/{run_id}", repo.GetActionRun)
|
m.Get("/{run_id}", repo.GetActionRun)
|
||||||
|
m.Delete("/{run_id}", reqToken(), reqAdmin(unit.TypeActions), repo.DeleteActionRun)
|
||||||
m.Get("/{run_id}/jobs", repo.ListActionRunJobs)
|
m.Get("/{run_id}/jobs", repo.ListActionRunJobs)
|
||||||
m.Get("/{run_id}/artifacts", repo.ListActionRunArtifacts)
|
m.Get("/{run_id}/artifacts", repo.ListActionRunArtifacts)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1032,6 +1032,68 @@ func GetActionRun(ctx *context.APIContext) {
|
||||||
ctx.JSON(http.StatusOK, convert.ToActionRun(ctx, run, ctx.Doer))
|
ctx.JSON(http.StatusOK, convert.ToActionRun(ctx, run, ctx.Doer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteActionRun removes a completed workflow run.
|
||||||
|
func DeleteActionRun(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /repos/{owner}/{repo}/actions/runs/{run_id} repository DeleteActionRun
|
||||||
|
// ---
|
||||||
|
// summary: Delete a completed workflow run.
|
||||||
|
// description: >
|
||||||
|
// Remove a particular workflow run. The workflow run must have completed (succeeded, failed, cancelled) for the
|
||||||
|
// operation to succeed. Otherwise, an error is returned.
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: run_id
|
||||||
|
// in: path
|
||||||
|
// description: id of the action run
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// description: Workflow run has been removed
|
||||||
|
// "400":
|
||||||
|
// "$ref": "#/responses/error"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
run, err := actions_model.GetRunByID(ctx, ctx.ParamsInt64(":run_id"))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, util.ErrNotExist) {
|
||||||
|
ctx.Error(http.StatusNotFound, "GetRunById", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Error(http.StatusInternalServerError, "GetRunByID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Repo.Repository.ID != run.RepoID {
|
||||||
|
ctx.Error(http.StatusNotFound, "GetRunById", util.ErrNotExist)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = actions_service.DeleteRun(ctx, run.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "DeleteRun", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ListActionRunJobs return a filtered list of jobs that belong to a single workflow run
|
// ListActionRunJobs return a filtered list of jobs that belong to a single workflow run
|
||||||
func ListActionRunJobs(ctx *context.APIContext) {
|
func ListActionRunJobs(ctx *context.APIContext) {
|
||||||
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs repository ListActionRunJobs
|
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs repository ListActionRunJobs
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ type ViewRunInfo struct {
|
||||||
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
|
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
|
||||||
CanRerun bool `json:"canRerun"`
|
CanRerun bool `json:"canRerun"`
|
||||||
CanDeleteArtifact bool `json:"canDeleteArtifact"`
|
CanDeleteArtifact bool `json:"canDeleteArtifact"`
|
||||||
|
CanDelete bool `json:"canDelete"`
|
||||||
Done bool `json:"done"`
|
Done bool `json:"done"`
|
||||||
Jobs []*ViewJob `json:"jobs"`
|
Jobs []*ViewJob `json:"jobs"`
|
||||||
Commit ViewCommit `json:"commit"`
|
Commit ViewCommit `json:"commit"`
|
||||||
|
|
@ -288,6 +289,7 @@ func getViewResponse(ctx *app_context.Context, req *ViewRequest, runIndex, jobIn
|
||||||
resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
|
resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
|
||||||
resp.State.Run.CanRerun = run.CanBeRerun() && ctx.Repo.CanWrite(unit.TypeActions)
|
resp.State.Run.CanRerun = run.CanBeRerun() && ctx.Repo.CanWrite(unit.TypeActions)
|
||||||
resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
|
resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
|
||||||
|
resp.State.Run.CanDelete = run.Status.IsDone() && ctx.IsUserRepoAdmin()
|
||||||
resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead of 'null' in json
|
resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead of 'null' in json
|
||||||
resp.State.Run.Status = run.Status.String()
|
resp.State.Run.Status = run.Status.String()
|
||||||
resp.State.Run.PreExecutionError = actions_model.TranslatePreExecutionError(ctx.Locale, run)
|
resp.State.Run.PreExecutionError = actions_model.TranslatePreExecutionError(ctx.Locale, run)
|
||||||
|
|
@ -600,6 +602,32 @@ func Cancel(ctx *app_context.Context) {
|
||||||
ctx.JSON(http.StatusOK, struct{}{})
|
ctx.JSON(http.StatusOK, struct{}{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DeleteRun(ctx *app_context.Context) {
|
||||||
|
runIndex := ctx.ParamsInt64("run")
|
||||||
|
|
||||||
|
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, util.ErrNotExist) {
|
||||||
|
log.Debug("Run at index %d in repository %d does not exist", runIndex, ctx.Repo.Repository.ID)
|
||||||
|
ctx.JSONOK()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Could not load run at index %d in repository %d: %s", runIndex, ctx.Repo.Repository.ID, err)
|
||||||
|
errorMessage := ctx.Locale.Tr("actions.runs.delete.error_could_not_load_run")
|
||||||
|
ctx.JSON(http.StatusInternalServerError, map[string]any{"message": errorMessage})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = actions_service.DeleteRun(ctx, run.ID); err != nil {
|
||||||
|
log.Debug("Could not delete run %d: %s", run.ID, err)
|
||||||
|
errorMessage := ctx.Locale.Tr("actions.runs.delete.error_could_not_delete_run")
|
||||||
|
ctx.JSON(http.StatusInternalServerError, map[string]any{"message": errorMessage})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSONOK()
|
||||||
|
}
|
||||||
|
|
||||||
// getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs.
|
// getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs.
|
||||||
// Any error will be written to the ctx.
|
// 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.
|
// It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0.
|
||||||
|
|
|
||||||
|
|
@ -1535,6 +1535,7 @@ func registerRoutes(m *web.Route) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
|
m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
|
||||||
|
m.Post("/delete", reqRepoAdmin, actions.DeleteRun)
|
||||||
m.Get("/artifacts", actions.ArtifactsView)
|
m.Get("/artifacts", actions.ArtifactsView)
|
||||||
m.Get("/artifacts/{artifact_name_or_id}", actions.ArtifactsDownloadView)
|
m.Get("/artifacts/{artifact_name_or_id}", actions.ArtifactsDownloadView)
|
||||||
m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView)
|
m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView)
|
||||||
|
|
|
||||||
5
services/actions/TestDeleteJobsOfRun/action_run.yml
Normal file
5
services/actions/TestDeleteJobsOfRun/action_run.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
- id: 34901
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
|
||||||
|
- id: 34902
|
||||||
|
status: 5 # StatusWaiting
|
||||||
7
services/actions/TestDeleteJobsOfRun/action_run_job.yml
Normal file
7
services/actions/TestDeleteJobsOfRun/action_run_job.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
- id: 47301
|
||||||
|
run_id: 34901
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
|
||||||
|
- id: 47302
|
||||||
|
run_id: 34902
|
||||||
|
status: 5 # StatusWaiting
|
||||||
11
services/actions/TestDeleteJobsOfRun/action_task.yml
Normal file
11
services/actions/TestDeleteJobsOfRun/action_task.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
- id: 87601
|
||||||
|
job_id: 47301
|
||||||
|
log_filename: somebody/somerepo/15631/87601.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
|
||||||
|
- id: 87602
|
||||||
|
job_id: 47302
|
||||||
|
log_filename: somebody/somerepo/15632/87602.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 5 # StatusWaiting
|
||||||
14
services/actions/TestDeleteJobsOfRun/action_task_output.yml
Normal file
14
services/actions/TestDeleteJobsOfRun/action_task_output.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
- id: 90041
|
||||||
|
task_id: 87601
|
||||||
|
output_key: one
|
||||||
|
output_value: a
|
||||||
|
|
||||||
|
- id: 90042
|
||||||
|
task_id: 87601
|
||||||
|
output_key: two
|
||||||
|
output_value: b
|
||||||
|
|
||||||
|
- id: 90043
|
||||||
|
task_id: 87602
|
||||||
|
output_key: three
|
||||||
|
output_value: c
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
- id: 98801
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87601
|
||||||
|
status: 1 # StatusSuccess
|
||||||
|
|
||||||
|
- id: 98802
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87602
|
||||||
|
status: 6 # StatusRunning
|
||||||
9
services/actions/TestDeleteRun/action_artifact.yml
Normal file
9
services/actions/TestDeleteRun/action_artifact.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
- id: 19401
|
||||||
|
run_id: 34901
|
||||||
|
status: 2 # ArtifactStatusUploadConfirmed
|
||||||
|
- id: 19402
|
||||||
|
run_id: 34901
|
||||||
|
status: 1 # ArtifactStatusUploadPending
|
||||||
|
- id: 19403
|
||||||
|
run_id: 34902
|
||||||
|
status: 2 # ArtifactStatusUploadConfirmed
|
||||||
5
services/actions/TestDeleteRun/action_run.yml
Normal file
5
services/actions/TestDeleteRun/action_run.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
- id: 34901
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
|
||||||
|
- id: 34902
|
||||||
|
status: 5 # StatusWaiting
|
||||||
7
services/actions/TestDeleteRun/action_run_job.yml
Normal file
7
services/actions/TestDeleteRun/action_run_job.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
- id: 47301
|
||||||
|
run_id: 34901
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
|
||||||
|
- id: 47302
|
||||||
|
run_id: 34902
|
||||||
|
status: 5 # StatusWaiting
|
||||||
11
services/actions/TestDeleteRun/action_task.yml
Normal file
11
services/actions/TestDeleteRun/action_task.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
- id: 87601
|
||||||
|
job_id: 47301
|
||||||
|
log_filename: somebody/somerepo/15631/87601.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
|
||||||
|
- id: 87602
|
||||||
|
job_id: 47302
|
||||||
|
log_filename: somebody/somerepo/15632/87602.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 5 # StatusWaiting
|
||||||
14
services/actions/TestDeleteRun/action_task_output.yml
Normal file
14
services/actions/TestDeleteRun/action_task_output.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
- id: 90041
|
||||||
|
task_id: 87601
|
||||||
|
output_key: one
|
||||||
|
output_value: a
|
||||||
|
|
||||||
|
- id: 90042
|
||||||
|
task_id: 87601
|
||||||
|
output_key: two
|
||||||
|
output_value: b
|
||||||
|
|
||||||
|
- id: 90043
|
||||||
|
task_id: 87602
|
||||||
|
output_key: three
|
||||||
|
output_value: c
|
||||||
9
services/actions/TestDeleteRun/action_task_step.yml
Normal file
9
services/actions/TestDeleteRun/action_task_step.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
- id: 98801
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87601
|
||||||
|
status: 1 # StatusSuccess
|
||||||
|
|
||||||
|
- id: 98802
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87602
|
||||||
|
status: 6 # StatusRunning
|
||||||
6
services/actions/TestDeleteTask/action_runner.yml
Normal file
6
services/actions/TestDeleteTask/action_runner.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
- id: 41601
|
||||||
|
uuid: 61a71de6-8127-40c9-a30e-63647e93edad
|
||||||
|
ephemeral: true
|
||||||
|
- id: 41602
|
||||||
|
uuid: fae04d20-98ae-4b69-8439-213dac373e19
|
||||||
|
ephemeral: false
|
||||||
16
services/actions/TestDeleteTask/action_task.yml
Normal file
16
services/actions/TestDeleteTask/action_task.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
- id: 87601
|
||||||
|
log_filename: somebody/somerepo/15631/87601.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 1 # StatusSuccess
|
||||||
|
runner_id: 41601
|
||||||
|
|
||||||
|
- id: 87602
|
||||||
|
log_filename: somebody/somerepo/15632/87602.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 6 # StatusRunning
|
||||||
|
|
||||||
|
- id: 87603
|
||||||
|
log_filename: somebody/somerepo/15631/87603.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
runner_id: 41602
|
||||||
14
services/actions/TestDeleteTask/action_task_output.yml
Normal file
14
services/actions/TestDeleteTask/action_task_output.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
- id: 90041
|
||||||
|
task_id: 87601
|
||||||
|
output_key: one
|
||||||
|
output_value: a
|
||||||
|
|
||||||
|
- id: 90042
|
||||||
|
task_id: 87601
|
||||||
|
output_key: two
|
||||||
|
output_value: b
|
||||||
|
|
||||||
|
- id: 90043
|
||||||
|
task_id: 87602
|
||||||
|
output_key: three
|
||||||
|
output_value: c
|
||||||
14
services/actions/TestDeleteTask/action_task_step.yml
Normal file
14
services/actions/TestDeleteTask/action_task_step.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
- id: 98801
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87601
|
||||||
|
status: 1 # StatusSuccess
|
||||||
|
|
||||||
|
- id: 98802
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87602
|
||||||
|
status: 6 # StatusRunning
|
||||||
|
|
||||||
|
- id: 98803
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87603
|
||||||
|
status: 2 # StatusFailure
|
||||||
47
services/actions/job.go
Normal file
47
services/actions/job.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"forgejo.org/models/actions"
|
||||||
|
"forgejo.org/models/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deleteJobsOfRun removes all jobs that belong to the given run, including its associated tasks. Each job has to be
|
||||||
|
// completed for the operation to succeed.
|
||||||
|
func deleteJobsOfRun(ctx context.Context, runID int64) error {
|
||||||
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
jobs, err := actions.GetRunJobsByRunID(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to load jobs of run %d: %w", runID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
if !job.Status.IsDone() {
|
||||||
|
return fmt.Errorf("unable to delete job %d because it has not completed yet", job.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, err := actions.GetTasksOfJob(ctx, job.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, task := range tasks {
|
||||||
|
err = deleteTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = actions.DeleteJob(ctx, job.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to delete job %d of run %d: %w", job.ID, job.RunID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
45
services/actions/job_test.go
Normal file
45
services/actions/job_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
|
"forgejo.org/models/unittest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeleteJobsOfRun(t *testing.T) {
|
||||||
|
t.Run("Deletes completed job", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestDeleteJobsOfRun")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901})
|
||||||
|
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 47301, RunID: run.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTask{JobID: job.ID}, 1)
|
||||||
|
|
||||||
|
require.NoError(t, deleteJobsOfRun(t.Context(), run.ID))
|
||||||
|
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 47302})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTask{JobID: job.ID}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error if job has not completed", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestDeleteJobsOfRun")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34902})
|
||||||
|
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 47302, RunID: run.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTask{JobID: job.ID}, 1)
|
||||||
|
|
||||||
|
err := deleteJobsOfRun(t.Context(), run.ID)
|
||||||
|
require.ErrorContains(t, err, "unable to delete job 47302 because it has not completed yet")
|
||||||
|
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTask{JobID: job.ID}, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -203,3 +204,30 @@ func checkJobRunsOnStaticMatrixError(ctx context.Context, job *actions_model.Act
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteRun removes a particular run including all associated artifacts, jobs, tasks, and logs. The run has to be
|
||||||
|
// completed for the operation to succeed.
|
||||||
|
func DeleteRun(ctx context.Context, runID int64) error {
|
||||||
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
run, err := actions_model.GetRunByID(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to load run %d: %w", runID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !run.Status.IsDone() {
|
||||||
|
return fmt.Errorf("cannot delete run %d because it has not completed yet", run.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = actions_model.SetArtifactsOfRunDeleted(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to delete artifacts of run %d: %w", run.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = deleteJobsOfRun(ctx, run.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to delete jobs of run %d: %w", run.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions_model.DeleteRun(ctx, run.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,3 +160,42 @@ func TestActions_consistencyCheckRun(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteRun(t *testing.T) {
|
||||||
|
t.Run("Removes run and its dependencies", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestDeleteRun")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901})
|
||||||
|
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionArtifact{RunID: run.ID}, 2)
|
||||||
|
|
||||||
|
require.NoError(t, DeleteRun(t.Context(), run.ID))
|
||||||
|
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionArtifact{
|
||||||
|
RunID: run.ID,
|
||||||
|
Status: int64(actions_model.ArtifactStatusPendingDeletion),
|
||||||
|
}, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error if run not done", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestDeleteRun")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34902})
|
||||||
|
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionArtifact{RunID: run.ID}, 1)
|
||||||
|
|
||||||
|
err := DeleteRun(t.Context(), run.ID)
|
||||||
|
require.ErrorContains(t, err, "cannot delete run 34902 because it has not completed yet")
|
||||||
|
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionArtifact{
|
||||||
|
RunID: run.ID,
|
||||||
|
Status: int64(actions_model.ArtifactStatusUploadConfirmed),
|
||||||
|
}, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -367,3 +367,40 @@ func convertTimestamp(timestamp *timestamppb.Timestamp) timeutil.TimeStamp {
|
||||||
}
|
}
|
||||||
return timeutil.TimeStamp(timestamp.AsTime().Unix())
|
return timeutil.TimeStamp(timestamp.AsTime().Unix())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteTask removes the given task with all associated steps, outputs, logs, and ephemeral runners, if any. For
|
||||||
|
// deleteTask to succeed, it must have completed. If it has not, an error is returned. If the given task does not exist,
|
||||||
|
// nothing happens.
|
||||||
|
func deleteTask(ctx context.Context, taskID int64) error {
|
||||||
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
task, err := actions_model.GetTaskByID(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, util.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unable to load task %d: %w", taskID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !task.Status.IsDone() {
|
||||||
|
return fmt.Errorf("unable to remove task %d because it has not completed yet", taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = actions_module.RemoveLogs(ctx, task.LogInStorage, task.LogFilename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to remove logs of task %d: %w", taskID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether an ephemeral runner has been used is determined based on whether it is assigned to a task.
|
||||||
|
// Consequently, ephemeral runners have to be cleaned up before any task can be removed.
|
||||||
|
err = actions_model.DeleteEphemeralRunner(ctx, task.RunnerID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to cleanup ephemeral runners before removing task %d: %w", taskID, err)
|
||||||
|
}
|
||||||
|
err = actions_model.DeleteTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to remove task %d: %w", task.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,12 @@ import (
|
||||||
|
|
||||||
actions_model "forgejo.org/models/actions"
|
actions_model "forgejo.org/models/actions"
|
||||||
repo_model "forgejo.org/models/repo"
|
repo_model "forgejo.org/models/repo"
|
||||||
|
"forgejo.org/models/unittest"
|
||||||
"forgejo.org/models/user"
|
"forgejo.org/models/user"
|
||||||
|
"forgejo.org/modules/actions"
|
||||||
|
|
||||||
|
runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -97,3 +101,90 @@ jobs:
|
||||||
require.NotEmpty(t, taskContext.Fields["gitea_runtime_token"].GetStringValue())
|
require.NotEmpty(t, taskContext.Fields["gitea_runtime_token"].GetStringValue())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteTask(t *testing.T) {
|
||||||
|
t.Run("Task removed with logs and ephemeral runner", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestDeleteTask")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 87601})
|
||||||
|
runner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 41601})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 2)
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 1)
|
||||||
|
|
||||||
|
_, err := actions.WriteLogs(t.Context(), task.LogFilename, 0, []*runnerv1.LogRow{{Content: "OK"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
logExists, err := actions.ExistsLogs(t.Context(), task.LogFilename)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, logExists)
|
||||||
|
|
||||||
|
require.NoError(t, deleteTask(t.Context(), task.ID))
|
||||||
|
|
||||||
|
logExists, err = actions.ExistsLogs(t.Context(), task.LogFilename)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, logExists)
|
||||||
|
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionTask{ID: task.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 0)
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 0)
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: runner.ID})
|
||||||
|
|
||||||
|
// Verify that other tasks have been left alone.
|
||||||
|
otherTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 87602})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: otherTask.ID}, 1)
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: otherTask.ID}, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Task removed and persistent runner kept", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestDeleteTask")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 87603})
|
||||||
|
runner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 41602})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 0)
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 1)
|
||||||
|
|
||||||
|
_, err := actions.WriteLogs(t.Context(), task.LogFilename, 0, []*runnerv1.LogRow{{Content: "OK"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
logExists, err := actions.ExistsLogs(t.Context(), task.LogFilename)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, logExists)
|
||||||
|
|
||||||
|
require.NoError(t, deleteTask(t.Context(), task.ID))
|
||||||
|
|
||||||
|
logExists, err = actions.ExistsLogs(t.Context(), task.LogFilename)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, logExists)
|
||||||
|
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionTask{ID: task.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 0)
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 0)
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: runner.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("No error if task does not exist", func(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionTask{ID: 87601})
|
||||||
|
require.NoError(t, deleteTask(t.Context(), 87601))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error if task is not done", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestDeleteTask")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 87602})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 1)
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 1)
|
||||||
|
|
||||||
|
err := deleteTask(t.Context(), task.ID)
|
||||||
|
require.ErrorContains(t, err, "unable to remove task 87602 because it has not completed yet")
|
||||||
|
|
||||||
|
// Verify nothing has been deleted.
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 1)
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
data-locale-cancel="{{ctx.Locale.Tr "cancel"}}"
|
data-locale-cancel="{{ctx.Locale.Tr "cancel"}}"
|
||||||
data-locale-rerun="{{ctx.Locale.Tr "rerun"}}"
|
data-locale-rerun="{{ctx.Locale.Tr "rerun"}}"
|
||||||
data-locale-rerun-all="{{ctx.Locale.Tr "rerun_all"}}"
|
data-locale-rerun-all="{{ctx.Locale.Tr "rerun_all"}}"
|
||||||
|
data-locale-delete="{{ctx.Locale.Tr "actions.runs.delete.button"}}"
|
||||||
|
data-locale-delete-error="{{ctx.Locale.Tr "actions.runs.delete.error"}}"
|
||||||
|
data-locale-confirm-delete="{{ctx.Locale.Tr "actions.runs.delete.confirm_action"}}"
|
||||||
data-locale-status-unknown="{{ctx.Locale.Tr "actions.status.unknown"}}"
|
data-locale-status-unknown="{{ctx.Locale.Tr "actions.status.unknown"}}"
|
||||||
data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}"
|
data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}"
|
||||||
data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}"
|
data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}"
|
||||||
|
|
|
||||||
49
templates/swagger/v1_json.tmpl
generated
49
templates/swagger/v1_json.tmpl
generated
|
|
@ -6089,6 +6089,55 @@
|
||||||
"$ref": "#/responses/notFound"
|
"$ref": "#/responses/notFound"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"description": "Remove a particular workflow run. The workflow run must have completed (succeeded, failed, cancelled) for the operation to succeed. Otherwise, an error is returned.\n",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"summary": "Delete a completed workflow run.",
|
||||||
|
"operationId": "DeleteActionRun",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repo",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "id of the action run",
|
||||||
|
"name": "run_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Workflow run has been removed"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts": {
|
"/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts": {
|
||||||
|
|
|
||||||
123
tests/integration/actions_run_test.go
Normal file
123
tests/integration/actions_run_test.go
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
|
repo_model "forgejo.org/models/repo"
|
||||||
|
"forgejo.org/models/unittest"
|
||||||
|
user_model "forgejo.org/models/user"
|
||||||
|
"forgejo.org/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestActionRunDeletion(t *testing.T) {
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID})
|
||||||
|
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||||
|
repo62 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 62, OwnerID: user2.ID})
|
||||||
|
|
||||||
|
sessUser2 := loginUser(t, user2.Name)
|
||||||
|
sessUser5 := loginUser(t, user5.Name)
|
||||||
|
|
||||||
|
t.Run("Run removed", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionRunDeletion")()
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901})
|
||||||
|
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionArtifact{RunID: run.ID}, 2)
|
||||||
|
runner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 41601})
|
||||||
|
|
||||||
|
requestURL := fmt.Sprintf("/%s/actions/runs/%d/delete", repo1.FullName(), run.Index)
|
||||||
|
request := NewRequest(t, "POST", requestURL)
|
||||||
|
response := sessUser2.MakeRequest(t, request, http.StatusOK)
|
||||||
|
assert.JSONEq(t, `{"ok":true}`, response.Body.String())
|
||||||
|
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionArtifact{
|
||||||
|
RunID: run.ID,
|
||||||
|
Status: int64(actions_model.ArtifactStatusPendingDeletion),
|
||||||
|
}, 2)
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: runner.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error if run has not completed", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionRunDeletion")()
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34902})
|
||||||
|
|
||||||
|
requestURL := fmt.Sprintf("/%s/actions/runs/%d/delete", repo1.FullName(), run.Index)
|
||||||
|
request := NewRequest(t, "POST", requestURL)
|
||||||
|
response := sessUser2.MakeRequest(t, request, http.StatusInternalServerError)
|
||||||
|
assert.JSONEq(t, `{"message":"Could not delete run."}`, response.Body.String())
|
||||||
|
|
||||||
|
// Verify that the run still exists.
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Nothing happens if run does not belong to repository", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionRunDeletion")()
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901})
|
||||||
|
|
||||||
|
requestURL := fmt.Sprintf("/%s/actions/runs/%d/delete", repo62.FullName(), run.Index)
|
||||||
|
request := NewRequest(t, "POST", requestURL)
|
||||||
|
response := sessUser2.MakeRequest(t, request, http.StatusOK)
|
||||||
|
assert.JSONEq(t, `{"ok":true}`, response.Body.String())
|
||||||
|
|
||||||
|
// Verify that the run still exists.
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Nothing happens if run does not exist", func(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{Index: 260871})
|
||||||
|
|
||||||
|
requestURL := fmt.Sprintf("/%s/actions/runs/%d/delete", repo1.FullName(), 260871)
|
||||||
|
request := NewRequest(t, "POST", requestURL)
|
||||||
|
response := sessUser2.MakeRequest(t, request, http.StatusOK)
|
||||||
|
assert.JSONEq(t, `{"ok":true}`, response.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Removal requires ownership", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionRunDeletion")()
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
isCollaborator, err := repo_model.IsCollaborator(t.Context(), repo1.ID, user5.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, isCollaborator)
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901})
|
||||||
|
|
||||||
|
requestURL := fmt.Sprintf("/%s/actions/runs/%d/delete", repo1.FullName(), run.Index)
|
||||||
|
request := NewRequest(t, "POST", requestURL)
|
||||||
|
MakeRequest(t, request, http.StatusNotFound)
|
||||||
|
|
||||||
|
// Verify that run still exists
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
|
||||||
|
request = NewRequest(t, "POST", requestURL)
|
||||||
|
sessUser5.MakeRequest(t, request, http.StatusNotFound)
|
||||||
|
|
||||||
|
// Verify that run still exists
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
|
||||||
|
request = NewRequest(t, "POST", requestURL)
|
||||||
|
sessUser2.MakeRequest(t, request, http.StatusOK)
|
||||||
|
|
||||||
|
// Verify that run no longer exists
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
actions_model "forgejo.org/models/actions"
|
actions_model "forgejo.org/models/actions"
|
||||||
|
repo_model "forgejo.org/models/repo"
|
||||||
unit_model "forgejo.org/models/unit"
|
unit_model "forgejo.org/models/unit"
|
||||||
"forgejo.org/models/unittest"
|
"forgejo.org/models/unittest"
|
||||||
user_model "forgejo.org/models/user"
|
user_model "forgejo.org/models/user"
|
||||||
|
|
@ -147,7 +148,7 @@ func TestActionViewsView(t *testing.T) {
|
||||||
runIndex: 187,
|
runIndex: 187,
|
||||||
jobIndex: 0,
|
jobIndex: 0,
|
||||||
attempt: 1,
|
attempt: 1,
|
||||||
expectedJSON: "{\"state\":{\"run\":{\"preExecutionError\":\"\",\"link\":\"/user5/repo4/actions/runs/187\",\"title\":\"update actions\",\"titleHTML\":\"update actions\",\"status\":\"success\",\"canCancel\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"description\":\"Commit <a href=\\\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\\\">c2d72f5484</a> pushed by <a href=\\\"/user1\\\">user1</a>\",\"done\":true,\"jobs\":[{\"id\":192,\"name\":\"job_2\",\"status\":\"success\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"c2d72f5484\",\"link\":\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\",\"pusher\":{\"displayName\":\"user1\",\"link\":\"/user1\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}}},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Success\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"success\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"success\"}],\"allAttempts\":[{\"number\":3,\"time_since_started_html\":\"_time_\",\"status\":\"running\",\"status_diagnostics\":[\"Running\"]},{\"number\":2,\"time_since_started_html\":\"_time_\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]},{\"number\":1,\"time_since_started_html\":\"_time_\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]}]}},\"logs\":{\"stepsLog\":[]}}\n",
|
expectedJSON: "{\"state\":{\"run\":{\"preExecutionError\":\"\",\"link\":\"/user5/repo4/actions/runs/187\",\"title\":\"update actions\",\"titleHTML\":\"update actions\",\"status\":\"success\",\"canCancel\":false,\"canDelete\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"description\":\"Commit <a href=\\\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\\\">c2d72f5484</a> pushed by <a href=\\\"/user1\\\">user1</a>\",\"done\":true,\"jobs\":[{\"id\":192,\"name\":\"job_2\",\"status\":\"success\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"c2d72f5484\",\"link\":\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\",\"pusher\":{\"displayName\":\"user1\",\"link\":\"/user1\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}}},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Success\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"success\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"success\"}],\"allAttempts\":[{\"number\":3,\"time_since_started_html\":\"_time_\",\"status\":\"running\",\"status_diagnostics\":[\"Running\"]},{\"number\":2,\"time_since_started_html\":\"_time_\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]},{\"number\":1,\"time_since_started_html\":\"_time_\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]}]}},\"logs\":{\"stepsLog\":[]}}\n",
|
||||||
expectedArtifacts: "{\"artifacts\":[{\"name\":\"multi-file-download\",\"size\":2048,\"status\":\"completed\"}]}\n",
|
expectedArtifacts: "{\"artifacts\":[{\"name\":\"multi-file-download\",\"size\":2048,\"status\":\"completed\"}]}\n",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -156,7 +157,7 @@ func TestActionViewsView(t *testing.T) {
|
||||||
runIndex: 209,
|
runIndex: 209,
|
||||||
jobIndex: 0,
|
jobIndex: 0,
|
||||||
attempt: 1,
|
attempt: 1,
|
||||||
expectedJSON: "{\"state\":{\"run\":{\"link\":\"/user5/repo4/actions/runs/209\",\"title\":\"A scheduled workflow\",\"titleHTML\":\"A scheduled workflow\",\"status\":\"waiting\",\"description\":\"Scheduled run of commit \\u003ca href=\\\"/user5/repo4/commit/64357baca84bfff631e7dfae5a3433b26d005646\\\"\\u003e64357baca8\\u003c/a\\u003e\",\"canCancel\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"done\":false,\"jobs\":[{\"id\":2153,\"name\":\"job_2\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"64357baca8\",\"link\":\"/user5/repo4/commit/64357baca84bfff631e7dfae5a3433b26d005646\",\"pusher\":{\"displayName\":\"forgejo-actions\",\"link\":\"/forgejo-actions\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}},\"preExecutionError\":\"\"},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Waiting for a runner with the following labels: debian, gpu\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"success\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"success\"}],\"allAttempts\":[{\"number\":1,\"time_since_started_html\":\"-\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]}]}},\"logs\":{\"stepsLog\":[]}}\n",
|
expectedJSON: "{\"state\":{\"run\":{\"link\":\"/user5/repo4/actions/runs/209\",\"title\":\"A scheduled workflow\",\"titleHTML\":\"A scheduled workflow\",\"status\":\"waiting\",\"description\":\"Scheduled run of commit \\u003ca href=\\\"/user5/repo4/commit/64357baca84bfff631e7dfae5a3433b26d005646\\\"\\u003e64357baca8\\u003c/a\\u003e\",\"canCancel\":false,\"canDelete\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"done\":false,\"jobs\":[{\"id\":2153,\"name\":\"job_2\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"64357baca8\",\"link\":\"/user5/repo4/commit/64357baca84bfff631e7dfae5a3433b26d005646\",\"pusher\":{\"displayName\":\"forgejo-actions\",\"link\":\"/forgejo-actions\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}},\"preExecutionError\":\"\"},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Waiting for a runner with the following labels: debian, gpu\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"success\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"success\"}],\"allAttempts\":[{\"number\":1,\"time_since_started_html\":\"-\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]}]}},\"logs\":{\"stepsLog\":[]}}\n",
|
||||||
expectedArtifacts: "{\"artifacts\":[]}\n",
|
expectedArtifacts: "{\"artifacts\":[]}\n",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -165,7 +166,7 @@ func TestActionViewsView(t *testing.T) {
|
||||||
runIndex: 210,
|
runIndex: 210,
|
||||||
jobIndex: 0,
|
jobIndex: 0,
|
||||||
attempt: 1,
|
attempt: 1,
|
||||||
expectedJSON: "{\"state\":{\"run\":{\"link\":\"/user5/repo4/actions/runs/210\",\"title\":\"A triggered run\",\"titleHTML\":\"A triggered run\",\"status\":\"waiting\",\"description\":\"Run of commit \\u003ca href=\\\"/user5/repo4/commit/f4100ac14112a3740490afb22b07b69b0b5d4e8b\\\"\\u003ef4100ac141\\u003c/a\\u003e triggered by \\u003ca href=\\\"/user29\\\"\\u003euser29\\u003c/a\\u003e\",\"canCancel\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"done\":false,\"jobs\":[{\"id\":2154,\"name\":\"mirror\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"f4100ac141\",\"link\":\"/user5/repo4/commit/f4100ac14112a3740490afb22b07b69b0b5d4e8b\",\"pusher\":{\"displayName\":\"user29\",\"link\":\"/user29\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}},\"preExecutionError\":\"\"},\"currentJob\":{\"title\":\"mirror\",\"details\":[\"Waiting for a runner with the following label: windows\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"running\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"waiting\"}],\"allAttempts\":[{\"number\":1,\"time_since_started_html\":\"-\",\"status\":\"waiting\",\"status_diagnostics\":[\"Waiting for a runner with the following label: windows\"]}]}},\"logs\":{\"stepsLog\":[]}}\n",
|
expectedJSON: "{\"state\":{\"run\":{\"link\":\"/user5/repo4/actions/runs/210\",\"title\":\"A triggered run\",\"titleHTML\":\"A triggered run\",\"status\":\"waiting\",\"description\":\"Run of commit \\u003ca href=\\\"/user5/repo4/commit/f4100ac14112a3740490afb22b07b69b0b5d4e8b\\\"\\u003ef4100ac141\\u003c/a\\u003e triggered by \\u003ca href=\\\"/user29\\\"\\u003euser29\\u003c/a\\u003e\",\"canCancel\":false,\"canDelete\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"done\":false,\"jobs\":[{\"id\":2154,\"name\":\"mirror\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"f4100ac141\",\"link\":\"/user5/repo4/commit/f4100ac14112a3740490afb22b07b69b0b5d4e8b\",\"pusher\":{\"displayName\":\"user29\",\"link\":\"/user29\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}},\"preExecutionError\":\"\"},\"currentJob\":{\"title\":\"mirror\",\"details\":[\"Waiting for a runner with the following label: windows\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"running\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"waiting\"}],\"allAttempts\":[{\"number\":1,\"time_since_started_html\":\"-\",\"status\":\"waiting\",\"status_diagnostics\":[\"Waiting for a runner with the following label: windows\"]}]}},\"logs\":{\"stepsLog\":[]}}\n",
|
||||||
expectedArtifacts: "{\"artifacts\":[]}\n",
|
expectedArtifacts: "{\"artifacts\":[]}\n",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -229,7 +230,7 @@ func TestActionViewsViewAttemptOutOfRange(t *testing.T) {
|
||||||
re = regexp.MustCompile(pattern)
|
re = regexp.MustCompile(pattern)
|
||||||
actualClean = re.ReplaceAllString(actualClean, `"time_since_started_html":"_time_"`)
|
actualClean = re.ReplaceAllString(actualClean, `"time_since_started_html":"_time_"`)
|
||||||
|
|
||||||
return assert.JSONEq(t, "{\"state\":{\"run\":{\"preExecutionError\":\"\",\"link\":\"/user5/repo4/actions/runs/190\",\"title\":\"job output\",\"titleHTML\":\"job output\",\"status\":\"success\",\"canCancel\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"description\":\"Commit <a href=\\\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\\\">c2d72f5484</a> pushed by <a href=\\\"/user1\\\">user1</a>\",\"done\":false,\"jobs\":[{\"id\":396,\"name\":\"job_2\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"c2d72f5484\",\"link\":\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\",\"pusher\":{\"displayName\":\"user1\",\"link\":\"/user1\"},\"branch\":{\"name\":\"test\",\"link\":\"/user5/repo4/src/branch/test\",\"isDeleted\":true}}},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Waiting for a runner with the following label: fedora\"],\"steps\":[],\"allAttempts\":null}},\"logs\":{\"stepsLog\":[]}}\n", actualClean)
|
return assert.JSONEq(t, "{\"state\":{\"run\":{\"preExecutionError\":\"\",\"link\":\"/user5/repo4/actions/runs/190\",\"title\":\"job output\",\"titleHTML\":\"job output\",\"status\":\"success\",\"canCancel\":false,\"canDelete\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"description\":\"Commit <a href=\\\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\\\">c2d72f5484</a> pushed by <a href=\\\"/user1\\\">user1</a>\",\"done\":false,\"jobs\":[{\"id\":396,\"name\":\"job_2\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"c2d72f5484\",\"link\":\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\",\"pusher\":{\"displayName\":\"user1\",\"link\":\"/user1\"},\"branch\":{\"name\":\"test\",\"link\":\"/user5/repo4/src/branch/test\",\"isDeleted\":true}}},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Waiting for a runner with the following label: fedora\"],\"steps\":[],\"allAttempts\":null}},\"logs\":{\"stepsLog\":[]}}\n", actualClean)
|
||||||
})
|
})
|
||||||
htmlDoc.AssertAttrEqual(t, selector, "data-initial-artifacts-response", "{\"artifacts\":[]}\n")
|
htmlDoc.AssertAttrEqual(t, selector, "data-initial-artifacts-response", "{\"artifacts\":[]}\n")
|
||||||
}
|
}
|
||||||
|
|
@ -258,3 +259,62 @@ func TestActionTabAccessibleFromRepo(t *testing.T) {
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActionViewRunDeletion(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionViewRunDeletion")()
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||||
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID})
|
||||||
|
|
||||||
|
sessionUser2 := loginUser(t, user2.Name)
|
||||||
|
sessionUser5 := loginUser(t, user5.Name)
|
||||||
|
|
||||||
|
isCollaborator, err := repo_model.IsCollaborator(t.Context(), repo1.ID, user5.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, isCollaborator)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
sess *TestSession
|
||||||
|
requestURL string
|
||||||
|
canDelete bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Repo owner can delete completed run",
|
||||||
|
sess: sessionUser2,
|
||||||
|
requestURL: fmt.Sprintf("/%s/actions/runs/3161/jobs/0/attempt/1", repo1.FullName()),
|
||||||
|
canDelete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Collaborator cannot delete completed run",
|
||||||
|
sess: sessionUser5,
|
||||||
|
requestURL: fmt.Sprintf("/%s/actions/runs/3161/jobs/0/attempt/1", repo1.FullName()),
|
||||||
|
canDelete: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Repo owner cannot delete running run",
|
||||||
|
sess: sessionUser2,
|
||||||
|
requestURL: fmt.Sprintf("/%s/actions/runs/3162/jobs/0/attempt/1", repo1.FullName()),
|
||||||
|
canDelete: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Collaborator cannot delete running run",
|
||||||
|
sess: sessionUser5,
|
||||||
|
requestURL: fmt.Sprintf("/%s/actions/runs/3162/jobs/0/attempt/1", repo1.FullName()),
|
||||||
|
canDelete: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "GET", testCase.requestURL)
|
||||||
|
resp := testCase.sess.MakeRequest(t, req, http.StatusOK)
|
||||||
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||||
|
htmlDoc.AssertAttrPredicate(t, "#repo-action-view", "data-initial-post-response", func(actual string) bool {
|
||||||
|
return assert.Contains(t, actual, fmt.Sprintf(`"canDelete":%t`, testCase.canDelete))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -671,6 +671,121 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActionsAPIDeleteActionRun(t *testing.T) {
|
||||||
|
t.Run("Run removed", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPIDeleteActionRun")()
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID})
|
||||||
|
session := loginUser(t, user2.Name)
|
||||||
|
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901})
|
||||||
|
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionArtifact{RunID: run.ID}, 2)
|
||||||
|
runner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 41601})
|
||||||
|
|
||||||
|
requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d", repo1.FullName(), run.ID)
|
||||||
|
request := NewRequest(t, "DELETE", requestURL)
|
||||||
|
request.AddTokenAuth(writeToken)
|
||||||
|
MakeRequest(t, request, http.StatusNoContent)
|
||||||
|
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||||
|
unittest.AssertCount(t, &actions_model.ActionArtifact{
|
||||||
|
RunID: run.ID,
|
||||||
|
Status: int64(actions_model.ArtifactStatusPendingDeletion),
|
||||||
|
}, 2)
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: runner.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Error if run has not completed", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPIDeleteActionRun")()
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID})
|
||||||
|
session := loginUser(t, user2.Name)
|
||||||
|
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34902})
|
||||||
|
|
||||||
|
requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d", repo1.FullName(), run.ID)
|
||||||
|
request := NewRequest(t, "DELETE", requestURL)
|
||||||
|
request.AddTokenAuth(writeToken)
|
||||||
|
MakeRequest(t, request, http.StatusInternalServerError)
|
||||||
|
|
||||||
|
// Verify that the run still exists.
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Not found if run does not belong to repository", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPIDeleteActionRun")()
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
repo62 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 62, OwnerID: user2.ID})
|
||||||
|
session := loginUser(t, user2.Name)
|
||||||
|
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901})
|
||||||
|
|
||||||
|
requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d", repo62.FullName(), run.ID)
|
||||||
|
request := NewRequest(t, "DELETE", requestURL)
|
||||||
|
request.AddTokenAuth(writeToken)
|
||||||
|
MakeRequest(t, request, http.StatusNotFound)
|
||||||
|
|
||||||
|
// Verify that the run still exists.
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("No found if run does not exist", func(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID})
|
||||||
|
session := loginUser(t, user2.Name)
|
||||||
|
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
|
||||||
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: 260871})
|
||||||
|
|
||||||
|
requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/260871", repo1.FullName())
|
||||||
|
request := NewRequest(t, "DELETE", requestURL)
|
||||||
|
request.AddTokenAuth(writeToken)
|
||||||
|
MakeRequest(t, request, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Run removal requires write token", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPIDeleteActionRun")()
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID})
|
||||||
|
session := loginUser(t, user2.Name)
|
||||||
|
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901})
|
||||||
|
|
||||||
|
requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d", repo1.FullName(), run.ID)
|
||||||
|
request := NewRequest(t, "DELETE", requestURL)
|
||||||
|
request.AddTokenAuth(readToken)
|
||||||
|
response := MakeRequest(t, request, http.StatusForbidden)
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorMessage *errorResponse
|
||||||
|
DecodeJSON(t, response, &errorMessage)
|
||||||
|
|
||||||
|
assert.Equal(t, "token does not have at least one of required scope(s): [write:repository]", errorMessage.Message)
|
||||||
|
|
||||||
|
// Verify that the run still exists.
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestActionsAPIListActionRunJobs(t *testing.T) {
|
func TestActionsAPIListActionRunJobs(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
- id: 19401
|
||||||
|
run_id: 34901
|
||||||
|
status: 2 # ArtifactStatusUploadConfirmed
|
||||||
|
- id: 19402
|
||||||
|
run_id: 34901
|
||||||
|
status: 1 # ArtifactStatusUploadPending
|
||||||
|
- id: 19403
|
||||||
|
run_id: 34902
|
||||||
|
status: 2 # ArtifactStatusUploadConfirmed
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
- id: 34901
|
||||||
|
repo_id: 1
|
||||||
|
owner_id: 2
|
||||||
|
index: 3161
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
|
||||||
|
- id: 34902
|
||||||
|
repo_id: 1
|
||||||
|
owner_id: 2
|
||||||
|
index: 3162
|
||||||
|
status: 5 # StatusWaiting
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
- id: 47301
|
||||||
|
run_id: 34901
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
|
||||||
|
- id: 47302
|
||||||
|
run_id: 34902
|
||||||
|
status: 5 # StatusWaiting
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
- id: 41601
|
||||||
|
owner_id: 0
|
||||||
|
repo_id: 1
|
||||||
|
uuid: 61a71de6-8127-40c9-a30e-63647e93edad
|
||||||
|
ephemeral: true
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
- id: 87601
|
||||||
|
job_id: 47301
|
||||||
|
log_filename: somebody/somerepo/15631/87601.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
runner_id: 41601
|
||||||
|
|
||||||
|
- id: 87602
|
||||||
|
job_id: 47302
|
||||||
|
log_filename: somebody/somerepo/15632/87602.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 5 # StatusWaiting
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
- id: 90041
|
||||||
|
task_id: 87601
|
||||||
|
output_key: one
|
||||||
|
output_value: a
|
||||||
|
|
||||||
|
- id: 90042
|
||||||
|
task_id: 87601
|
||||||
|
output_key: two
|
||||||
|
output_value: b
|
||||||
|
|
||||||
|
- id: 90043
|
||||||
|
task_id: 87602
|
||||||
|
output_key: three
|
||||||
|
output_value: c
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
- id: 98801
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87601
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
|
||||||
|
- id: 98802
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87602
|
||||||
|
status: 5 # StatusWaiting
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
- id: 393701
|
||||||
|
repo_id: 1
|
||||||
|
user_id: 5
|
||||||
|
mode: 2 # write
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
- id: 34901
|
||||||
|
repo_id: 1
|
||||||
|
owner_id: 2
|
||||||
|
index: 3161
|
||||||
|
status: 1 # StatusSuccess
|
||||||
|
|
||||||
|
- id: 34902
|
||||||
|
repo_id: 1
|
||||||
|
owner_id: 2
|
||||||
|
index: 3162
|
||||||
|
status: 6 # StatusRunning
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
- id: 47301
|
||||||
|
run_id: 34901
|
||||||
|
status: 1 # StatusSuccess
|
||||||
|
|
||||||
|
- id: 47302
|
||||||
|
run_id: 34902
|
||||||
|
status: 6 # StatusRunning
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
- id: 87601
|
||||||
|
job_id: 47301
|
||||||
|
log_filename: somebody/somerepo/15631/87601.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 1 # StatusSuccess
|
||||||
|
|
||||||
|
- id: 87602
|
||||||
|
job_id: 47302
|
||||||
|
log_filename: somebody/somerepo/15632/87602.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 6 # StatusRunning
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
- id: 98801
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87601
|
||||||
|
status: 1 # StatusSuccess
|
||||||
|
|
||||||
|
- id: 98802
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87602
|
||||||
|
status: 6 # StatusRunning
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
- id: 393701
|
||||||
|
repo_id: 1
|
||||||
|
user_id: 5
|
||||||
|
mode: 2 # write
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
- id: 19401
|
||||||
|
run_id: 34901
|
||||||
|
status: 2 # ArtifactStatusUploadConfirmed
|
||||||
|
- id: 19402
|
||||||
|
run_id: 34901
|
||||||
|
status: 1 # ArtifactStatusUploadPending
|
||||||
|
- id: 19403
|
||||||
|
run_id: 34902
|
||||||
|
status: 2 # ArtifactStatusUploadConfirmed
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
- id: 34901
|
||||||
|
repo_id: 1
|
||||||
|
owner_id: 2
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
|
||||||
|
- id: 34902
|
||||||
|
repo_id: 1
|
||||||
|
owner_id: 2
|
||||||
|
status: 5 # StatusWaiting
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
- id: 47301
|
||||||
|
run_id: 34901
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
|
||||||
|
- id: 47302
|
||||||
|
run_id: 34902
|
||||||
|
status: 5 # StatusWaiting
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
- id: 41601
|
||||||
|
owner_id: 0
|
||||||
|
repo_id: 1
|
||||||
|
uuid: 61a71de6-8127-40c9-a30e-63647e93edad
|
||||||
|
ephemeral: true
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
- id: 87601
|
||||||
|
job_id: 47301
|
||||||
|
log_filename: somebody/somerepo/15631/87601.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 2 # StatusFailure
|
||||||
|
runner_id: 41601
|
||||||
|
|
||||||
|
- id: 87602
|
||||||
|
job_id: 47302
|
||||||
|
log_filename: somebody/somerepo/15632/87602.log.zst
|
||||||
|
log_in_storage: false
|
||||||
|
status: 5 # StatusWaiting
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
- id: 90041
|
||||||
|
task_id: 87601
|
||||||
|
output_key: one
|
||||||
|
output_value: a
|
||||||
|
|
||||||
|
- id: 90042
|
||||||
|
task_id: 87601
|
||||||
|
output_key: two
|
||||||
|
output_value: b
|
||||||
|
|
||||||
|
- id: 90043
|
||||||
|
task_id: 87602
|
||||||
|
output_key: three
|
||||||
|
output_value: c
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
- id: 98801
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87601
|
||||||
|
status: 1 # StatusSuccess
|
||||||
|
|
||||||
|
- id: 98802
|
||||||
|
name: echo OK
|
||||||
|
task_id: 87602
|
||||||
|
status: 6 # StatusRunning
|
||||||
|
|
@ -6,6 +6,8 @@ const testLocale = {
|
||||||
approve: 'Locale Approve',
|
approve: 'Locale Approve',
|
||||||
cancel: 'Locale Cancel',
|
cancel: 'Locale Cancel',
|
||||||
rerun: 'Locale Re-run',
|
rerun: 'Locale Re-run',
|
||||||
|
delete: 'Locale Delete',
|
||||||
|
confirmDelete: '',
|
||||||
artifactsTitle: 'artifactTitleHere',
|
artifactsTitle: 'artifactTitleHere',
|
||||||
areYouSure: '',
|
areYouSure: '',
|
||||||
confirmDeleteArtifact: '',
|
confirmDeleteArtifact: '',
|
||||||
|
|
@ -168,6 +170,7 @@ function configureForMultipleAttemptTests({viewHistorical}) {
|
||||||
canApprove: true,
|
canApprove: true,
|
||||||
canCancel: true,
|
canCancel: true,
|
||||||
canRerun: true,
|
canRerun: true,
|
||||||
|
canDelete: false,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
commit: {
|
commit: {
|
||||||
pusher: {},
|
pusher: {},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import ActionRunStatus from './ActionRunStatus.vue';
|
||||||
import ActionJobStepList from './ActionJobStepList.vue';
|
import ActionJobStepList from './ActionJobStepList.vue';
|
||||||
import {toggleElem} from '../utils/dom.js';
|
import {toggleElem} from '../utils/dom.js';
|
||||||
import {GET, POST, DELETE} from '../modules/fetch.js';
|
import {GET, POST, DELETE} from '../modules/fetch.js';
|
||||||
|
import {showErrorToast} from '../modules/toast.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'RepoActionView',
|
name: 'RepoActionView',
|
||||||
|
|
@ -86,6 +87,7 @@ export default {
|
||||||
canCancel: false,
|
canCancel: false,
|
||||||
canApprove: false,
|
canApprove: false,
|
||||||
canRerun: false,
|
canRerun: false,
|
||||||
|
canDelete: false,
|
||||||
done: false,
|
done: false,
|
||||||
preExecutionError: '',
|
preExecutionError: '',
|
||||||
jobs: [
|
jobs: [
|
||||||
|
|
@ -237,6 +239,21 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteRun() {
|
||||||
|
if (!window.confirm(this.locale.confirmDelete)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await POST(`${this.run.link}/delete`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = this.workflowURL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showErrorToast(this.locale.deleteError, {duration: 5000});
|
||||||
|
},
|
||||||
|
|
||||||
// cancel a run
|
// cancel a run
|
||||||
cancelRun() {
|
cancelRun() {
|
||||||
POST(`${this.run.link}/cancel`);
|
POST(`${this.run.link}/cancel`);
|
||||||
|
|
@ -474,6 +491,9 @@ export default {
|
||||||
{{ locale.approve }}
|
{{ locale.approve }}
|
||||||
</button>
|
</button>
|
||||||
<div class="action-info-summary-actions" v-else>
|
<div class="action-info-summary-actions" v-else>
|
||||||
|
<button id="delete-run" class="ui basic small compact button red" @click="deleteRun()" v-if="run.canDelete">
|
||||||
|
{{ locale.delete }}
|
||||||
|
</button>
|
||||||
<button class="ui basic small compact button red" @click="cancelRun()" v-if="canCancel">
|
<button class="ui basic small compact button red" @click="cancelRun()" v-if="canCancel">
|
||||||
{{ locale.cancel }}
|
{{ locale.cancel }}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ export async function initRepositoryActionView() {
|
||||||
approve: el.getAttribute('data-locale-approve'),
|
approve: el.getAttribute('data-locale-approve'),
|
||||||
cancel: el.getAttribute('data-locale-cancel'),
|
cancel: el.getAttribute('data-locale-cancel'),
|
||||||
rerun: el.getAttribute('data-locale-rerun'),
|
rerun: el.getAttribute('data-locale-rerun'),
|
||||||
|
delete: el.getAttribute('data-locale-delete'),
|
||||||
|
confirmDelete: el.getAttribute('data-locale-confirm-delete'),
|
||||||
|
deleteError: el.getAttribute('data-locale-delete-error'),
|
||||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||||
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
||||||
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue