diff --git a/models/actions/run.go b/models/actions/run.go index 5f077940c5612..a846960632104 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -165,6 +165,17 @@ func (run *ActionRun) GetPullRequestEventPayload() (*api.PullRequestPayload, err return nil, fmt.Errorf("event %s is not a pull request event", run.Event) } +func (run *ActionRun) GetWorkflowRunEventPayload() (*api.WorkflowRunPayload, error) { + if run.Event == webhook_module.HookEventWorkflowRun { + var payload api.WorkflowRunPayload + if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil { + return nil, err + } + return &payload, nil + } + return nil, fmt.Errorf("event %s is not a pull request event", run.Event) +} + func (run *ActionRun) IsSchedule() bool { return run.ScheduleID > 0 } diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go index 29f7735d09b11..920ebd31384f4 100644 --- a/models/webhook/webhook_test.go +++ b/models/webhook/webhook_test.go @@ -73,7 +73,7 @@ func TestWebhook_EventsArray(t *testing.T) { "pull_request", "pull_request_assign", "pull_request_label", "pull_request_milestone", "pull_request_comment", "pull_request_review_approved", "pull_request_review_rejected", "pull_request_review_comment", "pull_request_sync", "pull_request_review_request", "wiki", "repository", "release", - "package", "status", "workflow_job", + "package", "status", "workflow_run", "workflow_job", }, (&Webhook{ HookEvent: &webhook_module.HookEvent{SendEverything: true}, diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index a538b6e290bec..170d9bb3dc66d 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -243,6 +243,10 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web webhook_module.HookEventPackage: return matchPackageEvent(payload.(*api.PackagePayload), evt) + case // workflow_run + webhook_module.HookEventWorkflowRun: + return matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt) + default: log.Warn("unsupported event %q", triggedEvent) return false @@ -698,3 +702,53 @@ func matchPackageEvent(payload *api.PackagePayload, evt *jobparser.Event) bool { } return matchTimes == len(evt.Acts()) } + +func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event) bool { + // with no special filter parameters + if len(evt.Acts()) == 0 { + return true + } + + matchTimes := 0 + // all acts conditions should be satisfied + for cond, vals := range evt.Acts() { + switch cond { + case "types": + action := payload.Action + for _, val := range vals { + if glob.MustCompile(val, '/').Match(action) { + matchTimes++ + break + } + } + case "workflows": + workflow := payload.Workflow + patterns, err := workflowpattern.CompilePatterns(vals...) + if err != nil { + break + } + if !workflowpattern.Skip(patterns, []string{workflow.Name}, &workflowpattern.EmptyTraceWriter{}) { + matchTimes++ + } + case "branches": + patterns, err := workflowpattern.CompilePatterns(vals...) + if err != nil { + break + } + if !workflowpattern.Skip(patterns, []string{payload.WorkflowRun.HeadBranch}, &workflowpattern.EmptyTraceWriter{}) { + matchTimes++ + } + case "branches-ignore": + patterns, err := workflowpattern.CompilePatterns(vals...) + if err != nil { + break + } + if !workflowpattern.Filter(patterns, []string{payload.WorkflowRun.HeadBranch}, &workflowpattern.EmptyTraceWriter{}) { + matchTimes++ + } + default: + log.Warn("workflow run event unsupported condition %q", cond) + } + } + return matchTimes == len(evt.Acts()) +} diff --git a/modules/structs/hook.go b/modules/structs/hook.go index aaa9fbc9d364d..cd0eef851a377 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -470,6 +470,22 @@ func (p *CommitStatusPayload) JSONPayload() ([]byte, error) { return json.MarshalIndent(p, "", " ") } +// WorkflowRunPayload represents a payload information of workflow run event. +type WorkflowRunPayload struct { + Action string `json:"action"` + Workflow *ActionWorkflow `json:"workflow"` + WorkflowRun *ActionWorkflowRun `json:"workflow_run"` + PullRequest *PullRequest `json:"pull_request,omitempty"` + Organization *Organization `json:"organization,omitempty"` + Repo *Repository `json:"repository"` + Sender *User `json:"sender"` +} + +// JSONPayload implements Payload +func (p *WorkflowRunPayload) JSONPayload() ([]byte, error) { + return json.MarshalIndent(p, "", " ") +} + // WorkflowJobPayload represents a payload information of workflow job event. type WorkflowJobPayload struct { Action string `json:"action"` diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 22409b4aff7fd..eca13825066de 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -86,9 +86,37 @@ type ActionArtifact struct { // ActionWorkflowRun represents a WorkflowRun type ActionWorkflowRun struct { - ID int64 `json:"id"` - RepositoryID int64 `json:"repository_id"` - HeadSha string `json:"head_sha"` + 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,omitempty"` + HeadSha string `json:"head_sha"` + HeadBranch string `json:"head_branch,omitempty"` + Status string `json:"status"` + Repository *Repository `json:"repository,omitempty"` + HeadRepository *Repository `json:"head_repository,omitempty"` + Conclusion string `json:"conclusion,omitempty"` + // swagger:strfmt date-time + StartedAt time.Time `json:"started_at,omitempty"` + // swagger:strfmt date-time + 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:"jobs"` + TotalCount int64 `json:"total_count"` } // ActionArtifactsResponse returns ActionArtifacts diff --git a/modules/webhook/type.go b/modules/webhook/type.go index 72ffde26a1574..89c6a4bfe5907 100644 --- a/modules/webhook/type.go +++ b/modules/webhook/type.go @@ -38,6 +38,7 @@ const ( HookEventPullRequestReview HookEventType = "pull_request_review" // Actions event only HookEventSchedule HookEventType = "schedule" + HookEventWorkflowRun HookEventType = "workflow_run" HookEventWorkflowJob HookEventType = "workflow_job" ) @@ -67,6 +68,7 @@ func AllEvents() []HookEventType { HookEventRelease, HookEventPackage, HookEventStatus, + HookEventWorkflowRun, HookEventWorkflowJob, } } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 96c99615f5d52..5b196a9977991 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2396,6 +2396,8 @@ settings.event_pull_request_review_request_desc = Pull request review requested settings.event_pull_request_approvals = Pull Request Approvals settings.event_pull_request_merge = Pull Request Merge settings.event_header_workflow = Workflow Events +settings.event_workflow_run = Workflow Run +settings.event_workflow_run_desc = Gitea Actions Workflow run queued, waiting, in progress, or completed. settings.event_workflow_job = Workflow Jobs settings.event_workflow_job_desc = Gitea Actions Workflow job queued, waiting, in progress, or completed. settings.event_package = Package diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5cd08a36181e2..38464dcbd6b02 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1247,7 +1247,11 @@ func Routes() *web.Router { }, reqToken(), reqAdmin()) m.Group("/actions", func() { m.Get("/tasks", repo.ListActionTasks) + m.Get("/runs", repo.GetWorkflowRuns) + m.Get("/runs/{run}", repo.GetWorkflowRun) + m.Get("/runs/{run}/jobs", repo.GetWorkflowJobs) m.Get("/runs/{run}/artifacts", repo.GetArtifactsOfRun) + m.Get("/jobs/{job_id}", repo.GetWorkflowJob) m.Get("/artifacts", repo.GetArtifacts) m.Group("/artifacts/{artifact_id}", func() { m.Get("", repo.GetArtifact) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index ed2017a37205c..67705b052bf03 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" @@ -637,7 +639,7 @@ func ActionsListRepositoryWorkflows(ctx *context.APIContext) { // "500": // "$ref": "#/responses/error" - workflows, err := actions_service.ListActionWorkflows(ctx) + workflows, err := convert.ListActionWorkflows(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository) if err != nil { ctx.APIErrorInternal(err) return @@ -683,7 +685,7 @@ func ActionsGetWorkflow(ctx *context.APIContext) { // "$ref": "#/responses/error" workflowID := ctx.PathParam("workflow_id") - workflow, err := actions_service.GetActionWorkflow(ctx, workflowID) + workflow, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID) if err != nil { if errors.Is(err, util.ErrNotExist) { ctx.APIError(http.StatusNotFound, err) @@ -873,6 +875,262 @@ 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 + } +} + +// GetWorkflowRuns Lists all runs for a repository run. +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: event + // in: query + // description: workflow event name + // type: string + // required: false + // - name: branch + // in: query + // description: workflow branch + // type: string + // required: false + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // 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, 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, _, err := db.GetByID[actions_model.ActionRun](ctx, runID) + + if err != nil || job.RepoID != ctx.Repo.Repository.ID { + ctx.APIError(http.StatusNotFound, util.ErrNotExist) + } + + convertedArtifact, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, job) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedArtifact) +} + +// 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 + // 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, nil, 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, _, err := db.GetByID[actions_model.ActionRunJob](ctx, jobID) + + if err != nil || job.RepoID != ctx.Repo.Repository.ID { + ctx.APIError(http.StatusNotFound, util.ErrNotExist) + } + + convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, job) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedWorkflowJob) +} + // 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/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go index 532d157e35df2..9d9d6a960a477 100644 --- a/routers/api/v1/utils/hook.go +++ b/routers/api/v1/utils/hook.go @@ -206,6 +206,7 @@ func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoI webhook_module.HookEventRelease: util.SliceContainsString(form.Events, string(webhook_module.HookEventRelease), true), webhook_module.HookEventPackage: util.SliceContainsString(form.Events, string(webhook_module.HookEventPackage), true), webhook_module.HookEventStatus: util.SliceContainsString(form.Events, string(webhook_module.HookEventStatus), true), + webhook_module.HookEventWorkflowRun: util.SliceContainsString(form.Events, string(webhook_module.HookEventWorkflowRun), true), webhook_module.HookEventWorkflowJob: util.SliceContainsString(form.Events, string(webhook_module.HookEventWorkflowJob), true), }, BranchFilter: form.BranchFilter, diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 2ec638926340a..2d81f890088d6 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -459,7 +459,12 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou } actions_service.CreateCommitStatus(ctx, job) - _ = job.LoadAttributes(ctx) + // Sync run status with db + job.Run = nil + if err := job.LoadAttributes(ctx); err != nil { + return err + } + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) return nil @@ -531,7 +536,16 @@ func Cancel(ctx *context_module.Context) { _ = job.LoadAttributes(ctx) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) } - + if len(updatedjobs) > 0 { + job := updatedjobs[0] + // Sync run status with db + job.Run = nil + if err := job.LoadAttributes(ctx); err != nil { + ctx.HTTPError(http.StatusInternalServerError, err.Error()) + return + } + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + } ctx.JSON(http.StatusOK, struct{}{}) } @@ -573,6 +587,14 @@ func Approve(ctx *context_module.Context) { actions_service.CreateCommitStatus(ctx, jobs...) + if len(updatedjobs) > 0 { + job := updatedjobs[0] + // Sync run status with db + job.Run = nil + _ = job.LoadAttributes(ctx) + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + } + for _, job := range updatedjobs { _ = job.LoadAttributes(ctx) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index d3151a86a26fb..006abafe5795d 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -185,6 +185,7 @@ func ParseHookEvent(form forms.WebhookForm) *webhook_module.HookEvent { webhook_module.HookEventRepository: form.Repository, webhook_module.HookEventPackage: form.Package, webhook_module.HookEventStatus: form.Status, + webhook_module.HookEventWorkflowRun: form.WorkflowRun, webhook_module.HookEventWorkflowJob: form.WorkflowJob, }, BranchFilter: form.BranchFilter, diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go index 2aeb0e8c96fc6..d74e3f43e2fd5 100644 --- a/services/actions/clear_tasks.go +++ b/services/actions/clear_tasks.go @@ -42,6 +42,10 @@ func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.Ac _ = job.LoadAttributes(ctx) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) } + if len(jobs) > 0 { + job := jobs[0] + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + } } } @@ -123,8 +127,11 @@ func CancelAbandonedJobs(ctx context.Context) error { } CreateCommitStatus(ctx, job) if updated { + // Sync run status with db + job.Run = nil _ = job.LoadAttributes(ctx) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) } } diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index c11bb5875f45c..e0cf1136f2f0a 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -78,6 +78,24 @@ func checkJobsOfRun(ctx context.Context, runID int64) error { _ = job.LoadAttributes(ctx) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) } + if len(jobs) > 0 { + runUpdated := true + for _, job := range jobs { + if !job.Status.IsDone() { + runUpdated = false + break + } + } + if runUpdated { + // Sync run status with db + jobs[0].Run = nil + if err := jobs[0].LoadAttributes(ctx); err != nil { + return err + } + run := jobs[0].Run + notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) + } + } return nil } diff --git a/services/actions/notifier.go b/services/actions/notifier.go index 831cde3523f73..1039d48cbda38 100644 --- a/services/actions/notifier.go +++ b/services/actions/notifier.go @@ -6,13 +6,16 @@ package actions import ( "context" + actions_model "code.gitea.io/gitea/models/actions" issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" packages_model "code.gitea.io/gitea/models/packages" perm_model "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" @@ -762,3 +765,41 @@ func (n *actionsNotifier) MigrateRepository(ctx context.Context, doer, u *user_m Sender: convert.ToUser(ctx, doer, nil), }).Notify(ctx) } + +func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) { + ctx = withMethod(ctx, "WorkflowRunStatusUpdate") + + var org *api.Organization + if repo.Owner.IsOrganization() { + org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner)) + } + + status := convert.ToWorkflowRunAction(run.Status) + + gitRepo, err := gitrepo.OpenRepository(context.Background(), repo) + if err != nil { + log.Error("OpenRepository: %v", err) + return + } + defer gitRepo.Close() + + convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + if err != nil { + log.Error("GetActionWorkflow: %v", err) + return + } + convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run) + if err != nil { + log.Error("ToActionWorkflowRun: %v", err) + return + } + + newNotifyInput(repo, sender, webhook_module.HookEventWorkflowRun).WithPayload(&api.WorkflowRunPayload{ + Action: status, + Workflow: convertedWorkflow, + WorkflowRun: convertedRun, + Organization: org, + Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}), + Sender: convert.ToUser(ctx, sender, nil), + }).Notify(ctx) +} diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index d179134798267..d58229728a9e8 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -178,7 +178,7 @@ func notify(ctx context.Context, input *notifyInput) error { return fmt.Errorf("gitRepo.GetCommit: %w", err) } - if skipWorkflows(input, commit) { + if skipWorkflows(ctx, input, commit) { return nil } @@ -243,7 +243,7 @@ func notify(ctx context.Context, input *notifyInput) error { return handleWorkflows(ctx, detectedWorkflows, commit, input, ref.String()) } -func skipWorkflows(input *notifyInput, commit *git.Commit) bool { +func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit) bool { // skip workflow runs with a configured skip-ci string in commit message or pr title if the event is push or pull_request(_sync) // https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs skipWorkflowEvents := []webhook_module.HookEventType{ @@ -263,6 +263,24 @@ func skipWorkflows(input *notifyInput, commit *git.Commit) bool { } } } + if input.Event == webhook_module.HookEventWorkflowRun { + wrun, ok := input.Payload.(*api.WorkflowRunPayload) + for i := 0; i < 5 && ok && wrun.WorkflowRun != nil; i++ { + if wrun.WorkflowRun.Event != "workflow_run" { + return false + } + r, _ := actions_model.GetRunByID(ctx, wrun.WorkflowRun.ID) + var err error + wrun, err = r.GetWorkflowRunEventPayload() + if err != nil { + log.Error("GetWorkflowRunEventPayload: %v", err) + return true + } + } + // skip workflow runs events exceeding the maxiumum of 5 recursive events + log.Debug("repo %s: skipped workflow_run because of recursive event of 5", input.Repo.RepoPath()) + return true + } return false } @@ -364,6 +382,15 @@ func handleWorkflows( continue } CreateCommitStatus(ctx, alljobs...) + if len(alljobs) > 0 { + job := alljobs[0] + err := job.LoadRun(ctx) + if err != nil { + log.Error("LoadRun: %v", err) + continue + } + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + } for _, job := range alljobs { notify_service.WorkflowJobStatusUpdate(ctx, input.Repo, input.Doer, job, nil) } diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index a30b1660630bb..c029c5a1a2c8e 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -157,6 +157,7 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) if err != nil { log.Error("LoadAttributes: %v", err) } + notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) for _, job := range allJobs { notify_service.WorkflowJobStatusUpdate(ctx, run.Repo, run.TriggerUser, job, nil) } diff --git a/services/actions/workflow.go b/services/actions/workflow.go index dc8a1dd34924f..7f4df0058dd7d 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -5,9 +5,6 @@ package actions import ( "fmt" - "net/http" - "net/url" - "path" "strings" actions_model "code.gitea.io/gitea/models/actions" @@ -31,61 +28,8 @@ import ( "github.com/nektos/act/pkg/model" ) -func getActionWorkflowPath(commit *git.Commit) string { - paths := []string{".gitea/workflows", ".github/workflows"} - for _, treePath := range paths { - if _, err := commit.SubTree(treePath); err == nil { - return treePath - } - } - return "" -} - -func getActionWorkflowEntry(ctx *context.APIContext, commit *git.Commit, folder string, entry *git.TreeEntry) *api.ActionWorkflow { - cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) - cfg := cfgUnit.ActionsConfig() - - defaultBranch, _ := commit.GetBranchName() - - workflowURL := fmt.Sprintf("%s/actions/workflows/%s", ctx.Repo.Repository.APIURL(), url.PathEscape(entry.Name())) - workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", ctx.Repo.Repository.HTMLURL(ctx), util.PathEscapeSegments(defaultBranch), util.PathEscapeSegments(folder), url.PathEscape(entry.Name())) - badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", ctx.Repo.Repository.HTMLURL(ctx), url.PathEscape(entry.Name()), url.QueryEscape(ctx.Repo.Repository.DefaultBranch)) - - // See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow - // State types: - // - active - // - deleted - // - disabled_fork - // - disabled_inactivity - // - disabled_manually - state := "active" - if cfg.IsWorkflowDisabled(entry.Name()) { - state = "disabled_manually" - } - - // The CreatedAt and UpdatedAt fields currently reflect the timestamp of the latest commit, which can later be refined - // by retrieving the first and last commits for the file history. The first commit would indicate the creation date, - // while the last commit would represent the modification date. The DeletedAt could be determined by identifying - // the last commit where the file existed. However, this implementation has not been done here yet, as it would likely - // cause a significant performance degradation. - createdAt := commit.Author.When - updatedAt := commit.Author.When - - return &api.ActionWorkflow{ - ID: entry.Name(), - Name: entry.Name(), - Path: path.Join(folder, entry.Name()), - State: state, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - URL: workflowURL, - HTMLURL: workflowRepoURL, - BadgeURL: badgeURL, - } -} - func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error { - workflow, err := GetActionWorkflow(ctx, workflowID) + workflow, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID) if err != nil { return err } @@ -102,44 +46,6 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl return repo_model.UpdateRepoUnit(ctx, cfgUnit) } -func ListActionWorkflows(ctx *context.APIContext) ([]*api.ActionWorkflow, error) { - defaultBranchCommit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) - if err != nil { - ctx.APIErrorInternal(err) - return nil, err - } - - entries, err := actions.ListWorkflows(defaultBranchCommit) - if err != nil { - ctx.APIError(http.StatusNotFound, err.Error()) - return nil, err - } - - folder := getActionWorkflowPath(defaultBranchCommit) - - workflows := make([]*api.ActionWorkflow, len(entries)) - for i, entry := range entries { - workflows[i] = getActionWorkflowEntry(ctx, defaultBranchCommit, folder, entry) - } - - return workflows, nil -} - -func GetActionWorkflow(ctx *context.APIContext, workflowID string) (*api.ActionWorkflow, error) { - entries, err := ListActionWorkflows(ctx) - if err != nil { - return nil, err - } - - for _, entry := range entries { - if entry.Name == workflowID { - return entry, nil - } - } - - return nil, util.NewNotExistErrorf("workflow %q not found", workflowID) -} - func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) error { if workflowID == "" { return util.ErrorWrapLocale( @@ -277,6 +183,15 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re log.Error("FindRunJobs: %v", err) } CreateCommitStatus(ctx, allJobs...) + if len(allJobs) > 0 { + job := allJobs[0] + err := job.LoadRun(ctx) + if err != nil { + log.Error("LoadRun: %v", err) + } else { + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + } + } for _, job := range allJobs { notify_service.WorkflowJobStatusUpdate(ctx, repo, doer, job, nil) } diff --git a/services/convert/convert.go b/services/convert/convert.go index ac2680766c040..0ce6bf15d39ef 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -7,6 +7,8 @@ package convert import ( "context" "fmt" + "net/url" + "path" "strconv" "strings" "time" @@ -14,6 +16,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" @@ -22,6 +25,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" @@ -230,6 +234,233 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action }, nil } +func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflowRun, error) { + err := run.LoadRepo(ctx) + if err != nil { + return nil, err + } + status, conclusion := ToActionsStatus(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: string(run.Event), + DisplayTitle: run.Title, + HeadBranch: git.RefName(run.Ref).BranchName(), + HeadSha: run.CommitSHA, + Status: status, + Conclusion: conclusion, + Path: fmt.Sprintf("%s@%s", run.WorkflowID, run.Ref), + Repository: ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}), + }, nil +} + +func ToWorkflowRunAction(status actions_model.Status) string { + var action string + switch status { + case actions_model.StatusWaiting, actions_model.StatusBlocked: + action = "requested" + case actions_model.StatusRunning: + action = "in_progress" + } + if status.IsDone() { + action = "completed" + } + return action +} + +func ToActionsStatus(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 +} + +// ToActionWorkflowJob convert a actions_model.ActionRunJob to an api.ActionWorkflowJob +// task is optional and can be nil +func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task *actions_model.ActionTask, 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 := ToActionsStatus(job.Status) + var runnerID int64 + var runnerName string + var steps []*api.ActionWorkflowStep + + if job.TaskID != 0 { + if task == nil { + task, _, err = db.GetByID[actions_model.ActionTask](ctx, job.TaskID) + if err != nil { + return nil, err + } + } + + 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 := ToActionsStatus(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 +} + +func getActionWorkflowPath(commit *git.Commit) string { + paths := []string{".gitea/workflows", ".github/workflows"} + for _, treePath := range paths { + if _, err := commit.SubTree(treePath); err == nil { + return treePath + } + } + return "" +} + +func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, folder string, entry *git.TreeEntry) *api.ActionWorkflow { + cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + + defaultBranch, _ := commit.GetBranchName() + + workflowURL := fmt.Sprintf("%s/actions/workflows/%s", repo.APIURL(), url.PathEscape(entry.Name())) + workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", repo.HTMLURL(ctx), util.PathEscapeSegments(defaultBranch), util.PathEscapeSegments(folder), url.PathEscape(entry.Name())) + badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", repo.HTMLURL(ctx), url.PathEscape(entry.Name()), url.QueryEscape(repo.DefaultBranch)) + + // See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow + // State types: + // - active + // - deleted + // - disabled_fork + // - disabled_inactivity + // - disabled_manually + state := "active" + if cfg.IsWorkflowDisabled(entry.Name()) { + state = "disabled_manually" + } + + // The CreatedAt and UpdatedAt fields currently reflect the timestamp of the latest commit, which can later be refined + // by retrieving the first and last commits for the file history. The first commit would indicate the creation date, + // while the last commit would represent the modification date. The DeletedAt could be determined by identifying + // the last commit where the file existed. However, this implementation has not been done here yet, as it would likely + // cause a significant performance degradation. + createdAt := commit.Author.When + updatedAt := commit.Author.When + + return &api.ActionWorkflow{ + ID: entry.Name(), + Name: entry.Name(), + Path: path.Join(folder, entry.Name()), + State: state, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + URL: workflowURL, + HTMLURL: workflowRepoURL, + BadgeURL: badgeURL, + } +} + +func ListActionWorkflows(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository) ([]*api.ActionWorkflow, error) { + defaultBranchCommit, err := gitrepo.GetBranchCommit(repo.DefaultBranch) + if err != nil { + return nil, err + } + + entries, err := actions.ListWorkflows(defaultBranchCommit) + if err != nil { + return nil, err + } + + folder := getActionWorkflowPath(defaultBranchCommit) + + workflows := make([]*api.ActionWorkflow, len(entries)) + for i, entry := range entries { + workflows[i] = getActionWorkflowEntry(ctx, repo, defaultBranchCommit, folder, entry) + } + + return workflows, nil +} + +func GetActionWorkflow(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository, workflowID string) (*api.ActionWorkflow, error) { + entries, err := ListActionWorkflows(ctx, gitrepo, repo) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if entry.Name == workflowID { + return entry, nil + } + } + + return nil, util.NewNotExistErrorf("workflow %q not found", workflowID) +} + // 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) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index d20220b7847a6..a54f3aac15612 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -234,6 +234,7 @@ type WebhookForm struct { Release bool Package bool Status bool + WorkflowRun bool WorkflowJob bool Active bool BranchFilter string `binding:"GlobPattern"` diff --git a/services/notify/notifier.go b/services/notify/notifier.go index 40428454be0af..875a70e5644a7 100644 --- a/services/notify/notifier.go +++ b/services/notify/notifier.go @@ -79,5 +79,7 @@ type Notifier interface { CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, sender *user_model.User, status *git_model.CommitStatus) + WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) + WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) } diff --git a/services/notify/notify.go b/services/notify/notify.go index 9f8be4b577373..0c6fdf9cef9df 100644 --- a/services/notify/notify.go +++ b/services/notify/notify.go @@ -376,6 +376,12 @@ func CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit } } +func WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) { + for _, notifier := range notifiers { + notifier.WorkflowRunStatusUpdate(ctx, repo, sender, run) + } +} + func WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { for _, notifier := range notifiers { notifier.WorkflowJobStatusUpdate(ctx, repo, sender, job, task) diff --git a/services/notify/null.go b/services/notify/null.go index 9c794a2342cf7..c3085d7c9eb0a 100644 --- a/services/notify/null.go +++ b/services/notify/null.go @@ -214,5 +214,8 @@ func (*NullNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.R func (*NullNotifier) CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, sender *user_model.User, status *git_model.CommitStatus) { } +func (*NullNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) { +} + func (*NullNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { } diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go index ce907bf0cb786..5bbc610fe5c7b 100644 --- a/services/webhook/dingtalk.go +++ b/services/webhook/dingtalk.go @@ -176,6 +176,12 @@ func (dc dingtalkConvertor) Status(p *api.CommitStatusPayload) (DingtalkPayload, return createDingtalkPayload(text, text, "Status Changed", p.TargetURL), nil } +func (dingtalkConvertor) WorkflowRun(p *api.WorkflowRunPayload) (DingtalkPayload, error) { + text, _ := getWorkflowRunPayloadInfo(p, noneLinkFormatter, true) + + return createDingtalkPayload(text, text, "Workflow Run", p.WorkflowRun.HTMLURL), nil +} + func (dingtalkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DingtalkPayload, error) { text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true) diff --git a/services/webhook/discord.go b/services/webhook/discord.go index 0e8a9aa67c87c..b3637acb17230 100644 --- a/services/webhook/discord.go +++ b/services/webhook/discord.go @@ -278,6 +278,12 @@ func (d discordConvertor) Status(p *api.CommitStatusPayload) (DiscordPayload, er return d.createPayload(p.Sender, text, "", p.TargetURL, color), nil } +func (d discordConvertor) WorkflowRun(p *api.WorkflowRunPayload) (DiscordPayload, error) { + text, color := getWorkflowRunPayloadInfo(p, noneLinkFormatter, false) + + return d.createPayload(p.Sender, text, "", p.WorkflowRun.HTMLURL, color), nil +} + func (d discordConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DiscordPayload, error) { text, color := getWorkflowJobPayloadInfo(p, noneLinkFormatter, false) diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index 274aaf90b3b28..c7d2309ac4a5e 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -172,6 +172,12 @@ func (fc feishuConvertor) Status(p *api.CommitStatusPayload) (FeishuPayload, err return newFeishuTextPayload(text), nil } +func (feishuConvertor) WorkflowRun(p *api.WorkflowRunPayload) (FeishuPayload, error) { + text, _ := getWorkflowRunPayloadInfo(p, noneLinkFormatter, true) + + return newFeishuTextPayload(text), nil +} + func (feishuConvertor) WorkflowJob(p *api.WorkflowJobPayload) (FeishuPayload, error) { text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true) diff --git a/services/webhook/general.go b/services/webhook/general.go index 251659e75ebf3..bcb8eb14a0d34 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -327,6 +327,37 @@ func getStatusPayloadInfo(p *api.CommitStatusPayload, linkFormatter linkFormatte return text, color } +func getWorkflowRunPayloadInfo(p *api.WorkflowRunPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) { + description := p.WorkflowRun.Conclusion + if description == "" { + description = p.WorkflowRun.Status + } + refLink := linkFormatter(p.WorkflowRun.HTMLURL, fmt.Sprintf("%s(#%d)", p.WorkflowRun.DisplayTitle, p.WorkflowRun.ID)+"["+base.ShortSha(p.WorkflowRun.HeadSha)+"]:"+description) + + text = fmt.Sprintf("Workflow Run %s: %s", p.Action, refLink) + switch description { + case "waiting": + color = orangeColor + case "queued": + color = orangeColorLight + case "success": + color = greenColor + case "failure": + color = redColor + case "cancelled": + color = yellowColor + case "skipped": + color = purpleColor + default: + color = greyColor + } + if withSender { + text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName)) + } + + return text, color +} + func getWorkflowJobPayloadInfo(p *api.WorkflowJobPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) { description := p.WorkflowJob.Conclusion if description == "" { diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go index 5bc7ba097e42a..3e9163f78c2f2 100644 --- a/services/webhook/matrix.go +++ b/services/webhook/matrix.go @@ -252,6 +252,12 @@ func (m matrixConvertor) Status(p *api.CommitStatusPayload) (MatrixPayload, erro return m.newPayload(text) } +func (m matrixConvertor) WorkflowRun(p *api.WorkflowRunPayload) (MatrixPayload, error) { + text, _ := getWorkflowRunPayloadInfo(p, htmlLinkFormatter, true) + + return m.newPayload(text) +} + func (m matrixConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MatrixPayload, error) { text, _ := getWorkflowJobPayloadInfo(p, htmlLinkFormatter, true) diff --git a/services/webhook/msteams.go b/services/webhook/msteams.go index 07d28c3867462..450a544b42ee4 100644 --- a/services/webhook/msteams.go +++ b/services/webhook/msteams.go @@ -318,6 +318,20 @@ func (m msteamsConvertor) Status(p *api.CommitStatusPayload) (MSTeamsPayload, er ), nil } +func (msteamsConvertor) WorkflowRun(p *api.WorkflowRunPayload) (MSTeamsPayload, error) { + title, color := getWorkflowRunPayloadInfo(p, noneLinkFormatter, false) + + return createMSTeamsPayload( + p.Repo, + p.Sender, + title, + "", + p.WorkflowRun.HTMLURL, + color, + &MSTeamsFact{"WorkflowRun:", p.WorkflowRun.DisplayTitle}, + ), nil +} + func (msteamsConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MSTeamsPayload, error) { title, color := getWorkflowJobPayloadInfo(p, noneLinkFormatter, false) diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 9e3f21de290ee..dc44460860081 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -5,10 +5,8 @@ package webhook import ( "context" - "fmt" actions_model "code.gitea.io/gitea/models/actions" - "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" @@ -18,6 +16,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/repository" @@ -956,72 +955,61 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_ org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner)) } - err := job.LoadAttributes(ctx) + status, _ := convert.ToActionsStatus(job.Status) + + convertedJob, err := convert.ToActionWorkflowJob(ctx, repo, task, job) if err != nil { - log.Error("Error loading job attributes: %v", err) + log.Error("ToActionWorkflowJob: %v", err) return } - jobIndex := 0 - jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID) + if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowJob, &api.WorkflowJobPayload{ + Action: status, + WorkflowJob: convertedJob, + Organization: org, + Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}), + Sender: convert.ToUser(ctx, sender, nil), + }); err != nil { + log.Error("PrepareWebhooks: %v", err) + } +} + +func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) { + source := EventSource{ + Repository: repo, + Owner: repo.Owner, + } + + var org *api.Organization + if repo.Owner.IsOrganization() { + org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner)) + } + + status := convert.ToWorkflowRunAction(run.Status) + + gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { - log.Error("Error loading getting run jobs: %v", err) + log.Error("OpenRepository: %v", err) return } - for i, j := range jobs { - if j.ID == job.ID { - jobIndex = i - break - } - } + defer gitRepo.Close() - status, conclusion := toActionStatus(job.Status) - var runnerID int64 - var runnerName string - var steps []*api.ActionWorkflowStep + convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + if err != nil { + log.Error("GetActionWorkflow: %v", err) + return + } - if task != nil { - 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(), - }) - } + convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run) + if err != nil { + log.Error("ToActionWorkflowRun: %v", err) + return } - if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowJob, &api.WorkflowJobPayload{ - Action: status, - WorkflowJob: &api.ActionWorkflowJob{ - ID: job.ID, - // missing api endpoint for this location - URL: fmt.Sprintf("%s/actions/runs/%d/jobs/%d", repo.APIURL(), job.RunID, 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(), - }, + if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowRun, &api.WorkflowRunPayload{ + Action: status, + Workflow: convertedWorkflow, + WorkflowRun: convertedRun, Organization: org, Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}), Sender: convert.ToUser(ctx, sender, nil), @@ -1029,29 +1017,3 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_ log.Error("PrepareWebhooks: %v", err) } } - -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 -} diff --git a/services/webhook/packagist.go b/services/webhook/packagist.go index 8829d95da606a..e6a00b0293364 100644 --- a/services/webhook/packagist.go +++ b/services/webhook/packagist.go @@ -114,6 +114,10 @@ func (pc packagistConvertor) Status(_ *api.CommitStatusPayload) (PackagistPayloa return PackagistPayload{}, nil } +func (pc packagistConvertor) WorkflowRun(_ *api.WorkflowRunPayload) (PackagistPayload, error) { + return PackagistPayload{}, nil +} + func (pc packagistConvertor) WorkflowJob(_ *api.WorkflowJobPayload) (PackagistPayload, error) { return PackagistPayload{}, nil } diff --git a/services/webhook/payloader.go b/services/webhook/payloader.go index adb7243fb14ba..c25d700c231d0 100644 --- a/services/webhook/payloader.go +++ b/services/webhook/payloader.go @@ -29,6 +29,7 @@ type payloadConvertor[T any] interface { Wiki(*api.WikiPayload) (T, error) Package(*api.PackagePayload) (T, error) Status(*api.CommitStatusPayload) (T, error) + WorkflowRun(*api.WorkflowRunPayload) (T, error) WorkflowJob(*api.WorkflowJobPayload) (T, error) } @@ -81,6 +82,8 @@ func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module return convertUnmarshalledJSON(rc.Package, data) case webhook_module.HookEventStatus: return convertUnmarshalledJSON(rc.Status, data) + case webhook_module.HookEventWorkflowRun: + return convertUnmarshalledJSON(rc.WorkflowRun, data) case webhook_module.HookEventWorkflowJob: return convertUnmarshalledJSON(rc.WorkflowJob, data) } diff --git a/services/webhook/slack.go b/services/webhook/slack.go index 589ef3fe9bd68..3d645a55d0441 100644 --- a/services/webhook/slack.go +++ b/services/webhook/slack.go @@ -173,6 +173,12 @@ func (s slackConvertor) Status(p *api.CommitStatusPayload) (SlackPayload, error) return s.createPayload(text, nil), nil } +func (s slackConvertor) WorkflowRun(p *api.WorkflowRunPayload) (SlackPayload, error) { + text, _ := getWorkflowRunPayloadInfo(p, SlackLinkFormatter, true) + + return s.createPayload(text, nil), nil +} + func (s slackConvertor) WorkflowJob(p *api.WorkflowJobPayload) (SlackPayload, error) { text, _ := getWorkflowJobPayloadInfo(p, SlackLinkFormatter, true) diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go index ca74eabe1c4e9..ae195758b9721 100644 --- a/services/webhook/telegram.go +++ b/services/webhook/telegram.go @@ -180,6 +180,12 @@ func (t telegramConvertor) Status(p *api.CommitStatusPayload) (TelegramPayload, return createTelegramPayloadHTML(text), nil } +func (telegramConvertor) WorkflowRun(p *api.WorkflowRunPayload) (TelegramPayload, error) { + text, _ := getWorkflowRunPayloadInfo(p, htmlLinkFormatter, true) + + return createTelegramPayloadHTML(text), nil +} + func (telegramConvertor) WorkflowJob(p *api.WorkflowJobPayload) (TelegramPayload, error) { text, _ := getWorkflowJobPayloadInfo(p, htmlLinkFormatter, true) diff --git a/services/webhook/wechatwork.go b/services/webhook/wechatwork.go index 2b19822caf6f6..187531740658b 100644 --- a/services/webhook/wechatwork.go +++ b/services/webhook/wechatwork.go @@ -181,6 +181,12 @@ func (wc wechatworkConvertor) Status(p *api.CommitStatusPayload) (WechatworkPayl return newWechatworkMarkdownPayload(text), nil } +func (wc wechatworkConvertor) WorkflowRun(p *api.WorkflowRunPayload) (WechatworkPayload, error) { + text, _ := getWorkflowRunPayloadInfo(p, noneLinkFormatter, true) + + return newWechatworkMarkdownPayload(text), nil +} + func (wc wechatworkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (WechatworkPayload, error) { text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true) diff --git a/templates/repo/settings/webhook/settings.tmpl b/templates/repo/settings/webhook/settings.tmpl index 16ad263e42a58..b8d9609391ff0 100644 --- a/templates/repo/settings/webhook/settings.tmpl +++ b/templates/repo/settings/webhook/settings.tmpl @@ -263,6 +263,16 @@ <div class="fourteen wide column"> <label>{{ctx.Locale.Tr "repo.settings.event_header_workflow"}}</label> </div> + <!-- Workflow Run Event --> + <div class="seven wide column"> + <div class="field"> + <div class="ui checkbox"> + <input name="workflow_run" type="checkbox" {{if .Webhook.HookEvents.Get "workflow_run"}}checked{{end}}> + <label>{{ctx.Locale.Tr "repo.settings.event_workflow_run"}}</label> + <span class="help">{{ctx.Locale.Tr "repo.settings.event_workflow_run_desc"}}</span> + </div> + </div> + </div> <!-- Workflow Job Event --> <div class="seven wide column"> <div class="field"> diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d0e41e8094ea4..551d41e37078f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -4187,6 +4187,52 @@ } } }, + "/repos/{owner}/{repo}/actions/jobs/{job_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Gets a specific workflow job for a workflow run", + "operationId": "getWorkflowJob", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "id of the job", + "name": "job_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Artifact" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/jobs/{job_id}/logs": { "get": { "produces": [ @@ -4266,6 +4312,109 @@ } } }, + "/repos/{owner}/{repo}/actions/runs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Lists all runs for a repository run", + "operationId": "getWorkflowRuns", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "workflow event name", + "name": "event", + "in": "query" + }, + { + "type": "string", + "description": "workflow branch", + "name": "branch", + "in": "query" + }, + { + "type": "string", + "description": "workflow status (pending, queued, in_progress, failure, success, skipped)", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ArtifactsList" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/actions/runs/{run}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Gets a specific workflow run", + "operationId": "GetWorkflowRun", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "id of the run", + "name": "run", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Artifact" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/artifacts": { "get": { "produces": [ @@ -4318,6 +4467,52 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run}/jobs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Lists all jobs for a workflow run", + "operationId": "getWorkflowJobs", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "runid of the workflow run", + "name": "run", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ArtifactsList" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/secrets": { "get": { "produces": [ @@ -19450,19 +19645,77 @@ "description": "ActionWorkflowRun represents a WorkflowRun", "type": "object", "properties": { + "completed_at": { + "type": "string", + "format": "date-time", + "x-go-name": "CompletedAt" + }, + "conclusion": { + "type": "string", + "x-go-name": "Conclusion" + }, + "display_title": { + "type": "string", + "x-go-name": "DisplayTitle" + }, + "event": { + "type": "string", + "x-go-name": "Event" + }, + "head_branch": { + "type": "string", + "x-go-name": "HeadBranch" + }, + "head_repository": { + "$ref": "#/definitions/Repository" + }, "head_sha": { "type": "string", "x-go-name": "HeadSha" }, + "html_url": { + "type": "string", + "x-go-name": "HTMLURL" + }, "id": { "type": "integer", "format": "int64", "x-go-name": "ID" }, + "path": { + "type": "string", + "x-go-name": "Path" + }, + "repository": { + "$ref": "#/definitions/Repository" + }, "repository_id": { "type": "integer", "format": "int64", "x-go-name": "RepositoryID" + }, + "run_attempt": { + "type": "integer", + "format": "int64", + "x-go-name": "RunAttempt" + }, + "run_number": { + "type": "integer", + "format": "int64", + "x-go-name": "RunNumber" + }, + "started_at": { + "type": "string", + "format": "date-time", + "x-go-name": "StartedAt" + }, + "status": { + "type": "string", + "x-go-name": "Status" + }, + "url": { + "type": "string", + "x-go-name": "URL" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 89df15b8de8c3..6d8f9a790aeac 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -707,8 +707,7 @@ jobs: assert.Equal(t, commitID, payloads[3].WorkflowJob.HeadSha) assert.Equal(t, "repo1", payloads[3].Repo.Name) assert.Equal(t, "user2/repo1", payloads[3].Repo.FullName) - assert.Contains(t, payloads[3].WorkflowJob.URL, fmt.Sprintf("/actions/runs/%d/jobs/%d", payloads[3].WorkflowJob.RunID, payloads[3].WorkflowJob.ID)) - assert.Contains(t, payloads[3].WorkflowJob.URL, payloads[3].WorkflowJob.RunURL) + assert.Contains(t, payloads[3].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[3].WorkflowJob.ID)) assert.Contains(t, payloads[3].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 0)) assert.Len(t, payloads[3].WorkflowJob.Steps, 1) @@ -745,8 +744,7 @@ jobs: assert.Equal(t, commitID, payloads[6].WorkflowJob.HeadSha) assert.Equal(t, "repo1", payloads[6].Repo.Name) assert.Equal(t, "user2/repo1", payloads[6].Repo.FullName) - assert.Contains(t, payloads[6].WorkflowJob.URL, fmt.Sprintf("/actions/runs/%d/jobs/%d", payloads[6].WorkflowJob.RunID, payloads[6].WorkflowJob.ID)) - assert.Contains(t, payloads[6].WorkflowJob.URL, payloads[6].WorkflowJob.RunURL) + assert.Contains(t, payloads[6].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[6].WorkflowJob.ID)) assert.Contains(t, payloads[6].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 1)) assert.Len(t, payloads[6].WorkflowJob.Steps, 2) }) diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go new file mode 100644 index 0000000000000..f142da7b226af --- /dev/null +++ b/tests/integration/workflow_run_api_check_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIWorkflowRunRepoApi(t *testing.T) { + defer tests.PrepareTestEnv(t)() + userUsername := "user5" + token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "GET", "/api/v1/repos/user5/repo4/actions/runs").AddTokenAuth(token) + runnerListResp := MakeRequest(t, req, http.StatusOK) + runnerList := api.ActionWorkflowRunsResponse{} + DecodeJSON(t, runnerListResp, &runnerList) + + assert.Len(t, runnerList.Entries, 4) + + for _, run := range runnerList.Entries { + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s", run.URL, "jobs")).AddTokenAuth(token) + jobsResp := MakeRequest(t, req, http.StatusOK) + jobList := api.ActionWorkflowJobsResponse{} + DecodeJSON(t, jobsResp, &jobList) + + // assert.NotEmpty(t, jobList.Entries) + for _, job := range jobList.Entries { + req := NewRequest(t, "GET", job.URL).AddTokenAuth(token) + jobsResp := MakeRequest(t, req, http.StatusOK) + apiJob := api.ActionWorkflowJob{} + DecodeJSON(t, jobsResp, &apiJob) + assert.Equal(t, job.ID, apiJob.ID) + assert.Equal(t, job.RunID, apiJob.RunID) + assert.Equal(t, job.Status, apiJob.Status) + assert.Equal(t, job.Conclusion, apiJob.Conclusion) + } + // assert.NotEmpty(t, run.ID) + // assert.NotEmpty(t, run.Status) + // assert.NotEmpty(t, run.Event) + // assert.NotEmpty(t, run.WorkflowID) + // assert.NotEmpty(t, run.HeadBranch) + // assert.NotEmpty(t, run.HeadSHA) + // assert.NotEmpty(t, run.CreatedAt) + // assert.NotEmpty(t, run.UpdatedAt) + // assert.NotEmpty(t, run.URL) + // assert.NotEmpty(t, run.HTMLURL) + // assert.NotEmpty(t, run.PullRequests) + // assert.NotEmpty(t, run.WorkflowURL) + // assert.NotEmpty(t, run.HeadCommit) + // assert.NotEmpty(t, run.HeadRepository) + // assert.NotEmpty(t, run.Repository) + // assert.NotEmpty(t, run.HeadRepository) + // assert.NotEmpty(t, run.HeadRepository.Owner) + // assert.NotEmpty(t, run.HeadRepository.Name) + // assert.NotEmpty(t, run.Repository.Owner) + // assert.NotEmpty(t, run.Repository.Name) + // assert.NotEmpty(t, run.HeadRepository.Owner.Login) + // assert.NotEmpty(t, run.HeadRepository.Name) + // assert.NotEmpty(t, run.Repository.Owner.Login) + // assert.NotEmpty(t, run.Repository.Name) + } +}