From 013a0af3853b10c03a472a93db169fd9dacb434c Mon Sep 17 00:00:00 2001 From: Christopher Homberger Date: Mon, 17 Mar 2025 17:27:25 +0100 Subject: [PATCH] wip --- modules/structs/repo_actions.go | 16 +- routers/api/v1/api.go | 2 + routers/api/v1/repo/action.go | 260 ++++++++++++++++++++++++++++++++ services/convert/convert.go | 113 ++++++++++++++ 4 files changed, 390 insertions(+), 1 deletion(-) diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 23bdb46d1b..7c8de3d776 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -89,10 +89,12 @@ type ActionWorkflowRun struct { ID int64 `json:"id"` URL string `json:"url"` HTMLURL string `json:"html_url"` + DisplayTitle string `json:"display_title"` + Path string `json:"path"` Event string `json:"event"` RunAttempt int64 `json:"run_attempt"` RunNumber int64 `json:"run_number"` - RepositoryID int64 `json:"repository_id"` + RepositoryID int64 `json:"repository_id,omitempty"` HeadSha string `json:"head_sha"` HeadBranch string `json:"head_branch,omitempty"` Status string `json:"status"` @@ -103,6 +105,18 @@ type ActionWorkflowRun struct { CompletedAt time.Time `json:"completed_at,omitempty"` } +// ActionArtifactsResponse returns ActionArtifacts +type ActionWorkflowRunsResponse struct { + Entries []*ActionWorkflowRun `json:"workflow_runs"` + TotalCount int64 `json:"total_count"` +} + +// ActionArtifactsResponse returns ActionArtifacts +type ActionWorkflowJobsResponse struct { + Entries []*ActionWorkflowJob `json:"workflow_jobs"` + TotalCount int64 `json:"total_count"` +} + // ActionArtifactsResponse returns ActionArtifacts type ActionArtifactsResponse struct { Entries []*ActionArtifact `json:"artifacts"` diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index bc76b5285e..a89fb6303a 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1243,6 +1243,8 @@ func Routes() *web.Router { }, reqToken(), reqAdmin()) m.Group("/actions", func() { m.Get("/tasks", repo.ListActionTasks) + m.Get("/runs", repo.GetWorkflowRuns) + m.Get("/runs/{run}/jobs", repo.GetWorkflowJobs) m.Get("/runs/{run}/artifacts", repo.GetArtifactsOfRun) m.Get("/artifacts", repo.GetArtifacts) m.Group("/artifacts/{artifact_id}", func() { diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 6b4ce37fcf..6463ae50d0 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -21,11 +21,13 @@ import ( repo_model "code.gitea.io/gitea/models/repo" secret_model "code.gitea.io/gitea/models/secret" "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/routers/api/v1/shared" "code.gitea.io/gitea/routers/api/v1/utils" actions_service "code.gitea.io/gitea/services/actions" @@ -868,6 +870,264 @@ func ActionsEnableWorkflow(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } +func convertToInternal(s string) actions_model.Status { + switch s { + case "pending": + return actions_model.StatusBlocked + case "queued": + return actions_model.StatusWaiting + case "in_progress": + return actions_model.StatusRunning + case "failure": + return actions_model.StatusFailure + case "success": + return actions_model.StatusSuccess + case "skipped": + return actions_model.StatusSkipped + default: + return actions_model.StatusUnknown + } +} + +// GetArtifacts Lists all artifacts for a repository. +func GetWorkflowRuns(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs repository getWorkflowRuns + // --- + // summary: Lists all runs for a repository run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: runid of the workflow run + // type: integer + // required: true + // - name: name + // in: query + // description: name of the artifact + // type: string + // required: false + // responses: + // "200": + // "$ref": "#/responses/ArtifactsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + repoID := ctx.Repo.Repository.ID + + opts := actions_model.FindRunOptions{ + RepoID: repoID, + ListOptions: utils.GetListOptions(ctx), + } + + if event := ctx.Req.URL.Query().Get("event"); event != "" { + opts.TriggerEvent = webhook.HookEventType(event) + } + if branch := ctx.Req.URL.Query().Get("branch"); branch != "" { + opts.Ref = string(git.RefNameFromBranch(branch)) + } + if status := ctx.Req.URL.Query().Get("status"); status != "" { + opts.Status = []actions_model.Status{convertToInternal(status)} + } + // if actor := ctx.Req.URL.Query().Get("actor"); actor != "" { + // user_model. + // opts.TriggerUserID = + // } + + runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + res := new(api.ActionWorkflowRunsResponse) + res.TotalCount = total + + res.Entries = make([]*api.ActionWorkflowRun, len(runs)) + for i := range runs { + convertedRun, err := convert.ToActionWorkflowRun(ctx.Repo.Repository, runs[i]) + if err != nil { + ctx.APIErrorInternal(err) + return + } + res.Entries[i] = convertedRun + } + + ctx.JSON(http.StatusOK, &res) +} + +// GetWorkflowRun Gets a specific workflow run. +func GetWorkflowRun(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun + // --- + // summary: Gets a specific workflow run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: id of the run + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Artifact" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + runID := ctx.PathParamInt64("run") + job, _, _ := db.GetByID[actions_model.ActionRun](ctx, runID) + + if job.RepoID != ctx.Repo.Repository.ID { + ctx.APIError(http.StatusNotFound, util.ErrNotExist) + } + + convertedArtifact, err := convert.ToActionWorkflowRun(ctx.Repo.Repository, job) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedArtifact) + return +} + +// GetWorkflowJobs Lists all jobs for a workflow run. +func GetWorkflowJobs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/jobs repository getWorkflowJobs + // --- + // summary: Lists all jobs for a workflow run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: runid of the workflow run + // type: integer + // required: true + // - name: name + // in: query + // description: name of the artifact + // type: string + // required: false + // responses: + // "200": + // "$ref": "#/responses/ArtifactsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + repoID := ctx.Repo.Repository.ID + + runID := ctx.PathParamInt64("run") + + artifacts, total, err := db.FindAndCount[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{ + RepoID: repoID, + RunID: runID, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + res := new(api.ActionWorkflowJobsResponse) + res.TotalCount = total + + res.Entries = make([]*api.ActionWorkflowJob, len(artifacts)) + for i := range artifacts { + convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, artifacts[i]) + if err != nil { + ctx.APIErrorInternal(err) + return + } + res.Entries[i] = convertedWorkflowJob + } + + ctx.JSON(http.StatusOK, &res) +} + +// GetWorkflowJob Gets a specific workflow job for a workflow run. +func GetWorkflowJob(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/jobs/{job_id} repository getWorkflowJob + // --- + // summary: Gets a specific workflow job for a workflow run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: job_id + // in: path + // description: id of the job + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Artifact" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + jobID := ctx.PathParamInt64("job_id") + job, _, _ := db.GetByID[actions_model.ActionRunJob](ctx, jobID) + + if job.RepoID != ctx.Repo.Repository.ID { + ctx.APIError(http.StatusNotFound, util.ErrNotExist) + } + + convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, job) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedWorkflowJob) + return +} + // GetArtifacts Lists all artifacts for a repository. func GetArtifactsOfRun(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/artifacts repository getArtifactsOfRun diff --git a/services/convert/convert.go b/services/convert/convert.go index ac2680766c..674e3f361f 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -14,6 +14,7 @@ import ( actions_model "code.gitea.io/gitea/models/actions" asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" @@ -230,6 +231,118 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action }, nil } +func ToActionWorkflowRun(repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflowRun, error) { + status, conclusion := toActionStatus(run.Status) + return &api.ActionWorkflowRun{ + ID: run.ID, + URL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID), + HTMLURL: run.HTMLURL(), + RunNumber: run.Index, + StartedAt: run.Started.AsLocalTime(), + CompletedAt: run.Stopped.AsLocalTime(), + Event: run.TriggerEvent, + DisplayTitle: run.Title, + HeadBranch: git.RefName(run.Ref).BranchName(), + HeadSha: run.CommitSHA, + Status: status, + Conclusion: conclusion, + Path: fmt.Sprint("%s@%s", run.WorkflowID, run.Ref), + }, nil +} + +func toActionStatus(status actions_model.Status) (string, string) { + var action string + var conclusion string + switch status { + // This is a naming conflict of the webhook between Gitea and GitHub Actions + case actions_model.StatusWaiting: + action = "queued" + case actions_model.StatusBlocked: + action = "waiting" + case actions_model.StatusRunning: + action = "in_progress" + } + if status.IsDone() { + action = "completed" + switch status { + case actions_model.StatusSuccess: + conclusion = "success" + case actions_model.StatusCancelled: + conclusion = "cancelled" + case actions_model.StatusFailure: + conclusion = "failure" + } + } + return action, conclusion +} + +func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, job *actions_model.ActionRunJob) (*api.ActionWorkflowJob, error) { + err := job.LoadAttributes(ctx) + if err != nil { + return nil, err + } + + jobIndex := 0 + jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID) + if err != nil { + return nil, err + } + for i, j := range jobs { + if j.ID == job.ID { + jobIndex = i + break + } + } + + status, conclusion := toActionStatus(job.Status) + var runnerID int64 + var runnerName string + var steps []*api.ActionWorkflowStep + + if job.TaskID != 0 { + task, _, _ := db.GetByID[actions_model.ActionTask](ctx, job.TaskID) + + runnerID = task.RunnerID + if runner, ok, _ := db.GetByID[actions_model.ActionRunner](ctx, runnerID); ok { + runnerName = runner.Name + } + for i, step := range task.Steps { + stepStatus, stepConclusion := toActionStatus(job.Status) + steps = append(steps, &api.ActionWorkflowStep{ + Name: step.Name, + Number: int64(i), + Status: stepStatus, + Conclusion: stepConclusion, + StartedAt: step.Started.AsTime().UTC(), + CompletedAt: step.Stopped.AsTime().UTC(), + }) + } + } + + return &api.ActionWorkflowJob{ + ID: job.ID, + // missing api endpoint for this location + URL: fmt.Sprintf("%s/actions/jobs/%d", repo.APIURL(), job.ID), + HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(), jobIndex), + RunID: job.RunID, + // Missing api endpoint for this location, artifacts are available under a nested url + RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), job.RunID), + Name: job.Name, + Labels: job.RunsOn, + RunAttempt: job.Attempt, + HeadSha: job.Run.CommitSHA, + HeadBranch: git.RefName(job.Run.Ref).BranchName(), + Status: status, + Conclusion: conclusion, + RunnerID: runnerID, + RunnerName: runnerName, + Steps: steps, + CreatedAt: job.Created.AsTime().UTC(), + StartedAt: job.Started.AsTime().UTC(), + CompletedAt: job.Stopped.AsTime().UTC(), + }, nil +} + // ToActionArtifact convert a actions_model.ActionArtifact to an api.ActionArtifact func ToActionArtifact(repo *repo_model.Repository, art *actions_model.ActionArtifact) (*api.ActionArtifact, error) { url := fmt.Sprintf("%s/actions/artifacts/%d", repo.APIURL(), art.ID)