diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2c10464438..d0b8531aea 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1238,6 +1238,7 @@ func Routes() *web.Route { m.Group("/runs", func() { m.Get("", repo.ListActionRuns) m.Get("/{run_id}", repo.GetActionRun) + m.Get("/{run_id}/jobs", repo.ListActionRunJobs) }) m.Group("/workflows", func() { diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 63baaf4437..ba430d2e64 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1031,3 +1031,68 @@ func GetActionRun(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToActionRun(ctx, run, ctx.Doer)) } + +// ListActionRunJobs return a filtered list of jobs that belong to a single workflow run +func ListActionRunJobs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs repository ListActionRunJobs + // --- + // summary: List jobs of a workflow run + // 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 workflow run + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionRunJobList" + // "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) + } else { + ctx.Error(http.StatusInternalServerError, "GetRunByID", err) + } + return + } + + // Action runs lives in its own table, therefore we check that the + // run with the requested ID is owned by the repository + if ctx.Repo.Repository.ID != run.RepoID { + ctx.Error(http.StatusNotFound, "GetRunById", util.ErrNotExist) + return + } + + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetRunJobsByRunID", err) + return + } + + response := make([]*api.ActionRunJob, 0, len(jobs)) + for _, job := range jobs { + response = append(response, convert.ToActionRunJob(job)) + } + + ctx.JSON(http.StatusOK, response) +} diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go index 21021e4076..6c99630ae1 100644 --- a/routers/api/v1/shared/runners.go +++ b/routers/api/v1/shared/runners.go @@ -74,18 +74,7 @@ func fromRunJobModelToResponse(job []*actions_model.ActionRunJob, labels []strin var res []*structs.ActionRunJob for i := range job { if len(labels) == 0 || labels[0] == "" && len(job[i].RunsOn) == 0 || job[i].ItRunsOn(labels) { - res = append(res, &structs.ActionRunJob{ - ID: job[i].ID, - Attempt: job[i].Attempt, - Handle: job[i].Handle, - RepoID: job[i].RepoID, - OwnerID: job[i].OwnerID, - Name: job[i].Name, - Needs: job[i].Needs, - RunsOn: job[i].RunsOn, - TaskID: job[i].TaskID, - Status: job[i].Status.String(), - }) + res = append(res, convert.ToActionRunJob(job[i])) } } return res diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index 31da865bb5..5ad16b21ae 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -526,3 +526,10 @@ type swaggerActionRun struct { // in:body Body api.ActionRun `json:"body"` } + +// ActionRunJobList +// swagger:response ActionRunJobList +type swaggerActionRunJobList struct { + // in:body + Body []api.ActionRunJob `json:"body"` +} diff --git a/services/convert/action.go b/services/convert/action.go index 703c1f1261..204139e3aa 100644 --- a/services/convert/action.go +++ b/services/convert/action.go @@ -47,3 +47,22 @@ func ToActionRun(ctx context.Context, run *actions_model.ActionRun, doer *user_m HTMLURL: run.HTMLURL(), } } + +func ToActionRunJob(job *actions_model.ActionRunJob) *api.ActionRunJob { + if job == nil { + return nil + } + + return &api.ActionRunJob{ + ID: job.ID, + Attempt: job.Attempt, + Handle: job.Handle, + RepoID: job.RepoID, + OwnerID: job.OwnerID, + Name: job.Name, + Needs: job.Needs, + RunsOn: job.RunsOn, + TaskID: job.TaskID, + Status: job.Status.String(), + } +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d45f9b2893..e9b10e1e52 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5882,6 +5882,56 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run_id}/jobs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List jobs of a workflow run", + "operationId": "ListActionRunJobs", + "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 workflow run", + "name": "run_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActionRunJobList" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/secrets": { "get": { "produces": [ @@ -30327,6 +30377,15 @@ "$ref": "#/definitions/ActionRun" } }, + "ActionRunJobList": { + "description": "ActionRunJobList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ActionRunJob" + } + } + }, "ActionRunList": { "description": "ActionRunList", "schema": { diff --git a/tests/integration/api_repo_actions_test.go b/tests/integration/api_repo_actions_test.go index fa56346a27..ccf333191e 100644 --- a/tests/integration/api_repo_actions_test.go +++ b/tests/integration/api_repo_actions_test.go @@ -4,6 +4,7 @@ package integration import ( + "context" "fmt" "io" "net/http" @@ -668,3 +669,109 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) { MakeRequest(t, request, http.StatusNotFound) }) } + +func TestActionsAPIListActionRunJobs(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Jobs", func(t *testing.T) { + for _, setup := range []struct { + runID, repoID int64 + }{ + {793, 4}, + {895, 4}, + } { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: setup.repoID}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + token := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository) + req := NewRequest(t, http.MethodGet, + fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/jobs", + repo.OwnerName, repo.Name, setup.runID, + ), + ).AddTokenAuth(token) + res := MakeRequest(t, req, http.StatusOK) + var jobList []*api.ActionRunJob + DecodeJSON(t, res, &jobList) + + correctJobList, err := actions_model.GetRunJobsByRunID(context.Background(), setup.runID) + require.NoError(t, err, "GetRunJobsByRunID") + assert.Len(t, jobList, len(correctJobList)) + + for i := range jobList { + expected := correctJobList[i] + actual := jobList[i] + assert.Equal(t, expected.ID, actual.ID) + assert.Equal(t, expected.Attempt, actual.Attempt) + assert.Equal(t, expected.Handle, actual.Handle) + assert.Equal(t, expected.RepoID, actual.RepoID) + assert.Equal(t, expected.OwnerID, actual.OwnerID) + assert.Equal(t, expected.Name, actual.Name) + assert.Equal(t, expected.Needs, actual.Needs) + assert.Equal(t, expected.RunsOn, actual.RunsOn) + assert.Equal(t, expected.TaskID, actual.TaskID) + assert.Equal(t, expected.Status.String(), actual.Status) + + if expected.ID == 195 { + assert.Equal(t, &api.ActionRunJob{ + ID: 195, + Attempt: 1, + Handle: "", + RepoID: 4, + OwnerID: 1, + Name: "job1 (2)", + Needs: nil, + RunsOn: nil, + TaskID: 50, + Status: "success", + }, actual) + } else if expected.ID == 197 { + assert.Equal(t, &api.ActionRunJob{ + ID: 197, + Attempt: 0, + Handle: "", + RepoID: 4, + OwnerID: 1, + Name: "job1 (1)", + Needs: nil, + RunsOn: []string{"postmarketOS"}, + TaskID: 54, + Status: "failure", + }, actual) + } + } + } + }) + + repoID := int64(4) + runID := int64(793) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + token := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeReadRepository) + + t.Run("Wrong Run ID", func(t *testing.T) { + req := NewRequest(t, http.MethodGet, + fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/jobs", + repo.OwnerName, repo.Name, runID+9999, + ), + ).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("Wrong Repo Name", func(t *testing.T) { + req := NewRequest(t, http.MethodGet, + fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/jobs", + repo.OwnerName, repo.Name+"_wrong_repo", runID, + ), + ).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("Wrong Owner", func(t *testing.T) { + req := NewRequest(t, http.MethodGet, + fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/jobs", + repo.OwnerName+"_wrong_owner", repo.Name, runID, + ), + ).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) +}