Skip to content

Commit bb5919e

Browse files
OFHansenMathieu Fenniak
authored andcommitted
feat(api): add new /repos/{owner}/{repo}/actions/runs/{run_id}/cancel API endpoint (#12957)
This new API endpoint makes it possible to cancel action runs via the API. Previously this was only natively possible through the UI, the same `CancelRun` func has been reused for this feature. ### Tests for Go changes - I added test coverage for Go changes... - [ ] 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 <!--start release-notes-assistant--> ## Release notes <!--URL:https://codeberg.org/forgejo/forgejo--> - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/12957): <!--number 12957 --><!--line 0 --><!--description ZmVhdChhcGkpOiBhZGQgbmV3IGAvcmVwb3Mve293bmVyfS97cmVwb30vYWN0aW9ucy9ydW5zL3tydW5faWR9L2NhbmNlbGAgQVBJIGVuZHBvaW50-->feat(api): add new `/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` API endpoint<!--description--> <!--end release-notes-assistant--> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12957 Reviewed-by: limiting-factor <limiting-factor@noreply.codeberg.org> Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org> Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
1 parent 3dc2b52 commit bb5919e

7 files changed

Lines changed: 251 additions & 0 deletions

File tree

routers/api/v1/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,6 +1256,7 @@ func Routes() *web.Route {
12561256
m.Get("", repo.ListActionRuns)
12571257
m.Get("/{run_id}", repo.GetActionRun)
12581258
m.Delete("/{run_id}", reqToken(), reqAdmin(unit.TypeActions), repo.DeleteActionRun)
1259+
m.Post("/{run_id}/cancel", reqToken(), reqRepoWriter(unit.TypeActions), repo.CancelActionRun)
12591260
m.Get("/{run_id}/jobs", repo.ListActionRunJobs)
12601261
m.Get("/{run_id}/logs", repo.GetActionRunLogs)
12611262
m.Get("/{run_id}/artifacts", repo.ListActionRunArtifacts)

routers/api/v1/repo/action.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,67 @@ func DeleteActionRun(ctx *context.APIContext) {
10951095
ctx.Status(http.StatusNoContent)
10961096
}
10971097

1098+
// CancelActionRun cancels a pending or running workflow run.
1099+
func CancelActionRun(ctx *context.APIContext) {
1100+
// swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel repository CancelActionRun
1101+
// ---
1102+
// summary: Cancel a pending or running workflow run.
1103+
// description: >
1104+
// Cancel a particular workflow run. Pending or running jobs of the run are cancelled. A run that has
1105+
// already finished, whether cancelled, failed, skipped or succeeded, is left unchanged.
1106+
// In both cases the endpoint responds with HTTP 204.
1107+
// produces:
1108+
// - application/json
1109+
// parameters:
1110+
// - name: owner
1111+
// in: path
1112+
// description: owner of the repo
1113+
// type: string
1114+
// required: true
1115+
// - name: repo
1116+
// in: path
1117+
// description: name of the repo
1118+
// type: string
1119+
// required: true
1120+
// - name: run_id
1121+
// in: path
1122+
// description: ID of the workflow run
1123+
// type: integer
1124+
// format: int64
1125+
// required: true
1126+
// responses:
1127+
// "204":
1128+
// description: Workflow run has been cancelled
1129+
// "403":
1130+
// "$ref": "#/responses/forbidden"
1131+
// "404":
1132+
// "$ref": "#/responses/notFound"
1133+
1134+
run, err := actions_model.GetRunByID(ctx, ctx.ParamsInt64(":run_id"))
1135+
if err != nil {
1136+
if errors.Is(err, util.ErrNotExist) {
1137+
ctx.Error(http.StatusNotFound, "GetRunById", err)
1138+
return
1139+
}
1140+
1141+
ctx.Error(http.StatusInternalServerError, "GetRunByID", err)
1142+
return
1143+
}
1144+
1145+
if ctx.Repo.Repository.ID != run.RepoID {
1146+
ctx.Error(http.StatusNotFound, "GetRunById", util.ErrNotExist)
1147+
return
1148+
}
1149+
1150+
err = actions_service.CancelRun(ctx, run)
1151+
if err != nil {
1152+
ctx.Error(http.StatusInternalServerError, "CancelRun", err)
1153+
return
1154+
}
1155+
1156+
ctx.Status(http.StatusNoContent)
1157+
}
1158+
10981159
// ListActionRunJobs return a filtered list of jobs that belong to a single workflow run
10991160
func ListActionRunJobs(ctx *context.APIContext) {
11001161
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs repository ListActionRunJobs

templates/swagger/v1_json.tmpl

Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/integration/api_repo_actions_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,124 @@ func TestActionsAPIDeleteActionRun(t *testing.T) {
786786
})
787787
}
788788

789+
func TestActionsAPICancelActionRun(t *testing.T) {
790+
t.Run("Run cancelled", func(t *testing.T) {
791+
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPICancelActionRun")()
792+
defer tests.PrepareTestEnv(t)()
793+
794+
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
795+
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID})
796+
session := loginUser(t, user2.Name)
797+
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
798+
799+
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 35011})
800+
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 48011, RunID: run.ID})
801+
assert.False(t, run.Status.IsDone())
802+
assert.False(t, job.Status.IsDone())
803+
804+
requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/cancel", repo1.FullName(), run.ID)
805+
request := NewRequest(t, "POST", requestURL)
806+
request.AddTokenAuth(writeToken)
807+
MakeRequest(t, request, http.StatusNoContent)
808+
809+
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
810+
assert.Equal(t, actions_model.StatusCancelled, run.Status)
811+
job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
812+
assert.Equal(t, actions_model.StatusCancelled, job.Status)
813+
})
814+
815+
t.Run("Already finished run is left unchanged", func(t *testing.T) {
816+
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPICancelActionRun")()
817+
defer tests.PrepareTestEnv(t)()
818+
819+
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
820+
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID})
821+
session := loginUser(t, user2.Name)
822+
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
823+
824+
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 35012})
825+
require.Equal(t, actions_model.StatusSuccess, run.Status)
826+
827+
requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/cancel", repo1.FullName(), run.ID)
828+
request := NewRequest(t, "POST", requestURL)
829+
request.AddTokenAuth(writeToken)
830+
MakeRequest(t, request, http.StatusNoContent)
831+
832+
// The finished run keeps its status.
833+
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
834+
assert.Equal(t, actions_model.StatusSuccess, run.Status)
835+
})
836+
837+
t.Run("Not found if run does not belong to repository", func(t *testing.T) {
838+
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPICancelActionRun")()
839+
defer tests.PrepareTestEnv(t)()
840+
841+
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
842+
repo62 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 62, OwnerID: user2.ID})
843+
session := loginUser(t, user2.Name)
844+
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
845+
846+
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 35011})
847+
assert.Equal(t, actions_model.StatusRunning, run.Status)
848+
849+
requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/cancel", repo62.FullName(), run.ID)
850+
request := NewRequest(t, "POST", requestURL)
851+
request.AddTokenAuth(writeToken)
852+
MakeRequest(t, request, http.StatusNotFound)
853+
854+
// Verify that the run was not cancelled.
855+
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
856+
assert.Equal(t, actions_model.StatusRunning, run.Status)
857+
})
858+
859+
t.Run("Not found if run does not exist", func(t *testing.T) {
860+
defer tests.PrepareTestEnv(t)()
861+
862+
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
863+
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID})
864+
session := loginUser(t, user2.Name)
865+
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
866+
867+
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: 260871})
868+
869+
requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/260871/cancel", repo1.FullName())
870+
request := NewRequest(t, "POST", requestURL)
871+
request.AddTokenAuth(writeToken)
872+
MakeRequest(t, request, http.StatusNotFound)
873+
})
874+
875+
t.Run("Run cancellation requires write token", func(t *testing.T) {
876+
defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPICancelActionRun")()
877+
defer tests.PrepareTestEnv(t)()
878+
879+
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
880+
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID})
881+
session := loginUser(t, user2.Name)
882+
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
883+
884+
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 35011})
885+
assert.Equal(t, actions_model.StatusRunning, run.Status)
886+
887+
requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/cancel", repo1.FullName(), run.ID)
888+
request := NewRequest(t, "POST", requestURL)
889+
request.AddTokenAuth(readToken)
890+
response := MakeRequest(t, request, http.StatusForbidden)
891+
892+
type errorResponse struct {
893+
Message string `json:"message"`
894+
}
895+
896+
var errorMessage *errorResponse
897+
DecodeJSON(t, response, &errorMessage)
898+
899+
assert.Equal(t, "token does not have at least one of required scope(s): [write:repository]", errorMessage.Message)
900+
901+
// Verify that the run was not cancelled.
902+
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
903+
assert.Equal(t, actions_model.StatusRunning, run.Status)
904+
})
905+
}
906+
789907
func TestActionsAPIListActionRunJobs(t *testing.T) {
790908
defer tests.PrepareTestEnv(t)()
791909

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
- id: 35011
2+
repo_id: 1
3+
owner_id: 2
4+
status: 6 # StatusRunning
5+
6+
- id: 35012
7+
repo_id: 1
8+
owner_id: 2
9+
status: 1 # StatusSuccess
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
- id: 48011
2+
run_id: 35011
3+
task_id: 72011
4+
status: 6 # StatusRunning
5+
6+
- id: 48012
7+
run_id: 35012
8+
status: 1 # StatusSuccess
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
- id: 72011
2+
job_id: 48011
3+
log_filename: cancel-test/72/011.log
4+
log_in_storage: false
5+
status: 6 # StatusRunning
6+
runner_id: 1

0 commit comments

Comments
 (0)