From edb24592fce1943b08f7acf13e64ec02d60d82ee Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 14 Mar 2025 21:07:36 +0100
Subject: [PATCH 01/56] wip event

---
 modules/structs/hook.go                       | 16 ++++++++
 modules/structs/repo_actions.go               | 12 ++++++
 modules/webhook/type.go                       |  1 +
 services/actions/clear_tasks.go               |  3 ++
 services/actions/notifier.go                  | 11 ++++++
 services/notify/notifier.go                   |  2 +
 services/notify/notify.go                     |  6 +++
 services/notify/null.go                       |  3 ++
 services/webhook/notifier.go                  | 39 +++++++++++++++++++
 templates/repo/settings/webhook/settings.tmpl |  9 +++++
 10 files changed, 102 insertions(+)

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..23bdb46d1b3aa 100644
--- a/modules/structs/repo_actions.go
+++ b/modules/structs/repo_actions.go
@@ -87,8 +87,20 @@ type ActionArtifact struct {
 // ActionWorkflowRun represents a WorkflowRun
 type ActionWorkflowRun struct {
 	ID           int64  `json:"id"`
+	URL          string `json:"url"`
+	HTMLURL      string `json:"html_url"`
+	Event        string `json:"event"`
+	RunAttempt   int64  `json:"run_attempt"`
+	RunNumber    int64  `json:"run_number"`
 	RepositoryID int64  `json:"repository_id"`
 	HeadSha      string `json:"head_sha"`
+	HeadBranch   string `json:"head_branch,omitempty"`
+	Status       string `json:"status"`
+	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
diff --git a/modules/webhook/type.go b/modules/webhook/type.go
index 72ffde26a1574..bcf8903b2b130 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"
 )
 
diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go
index 2aeb0e8c96fc6..bfa10563bcb32 100644
--- a/services/actions/clear_tasks.go
+++ b/services/actions/clear_tasks.go
@@ -125,6 +125,9 @@ func CancelAbandonedJobs(ctx context.Context) error {
 		if updated {
 			_ = job.LoadAttributes(ctx)
 			notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
+			if job.Run != nil {
+				notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
+			}
 		}
 	}
 
diff --git a/services/actions/notifier.go b/services/actions/notifier.go
index 1a23b4e0c5a05..1040607a5600b 100644
--- a/services/actions/notifier.go
+++ b/services/actions/notifier.go
@@ -6,6 +6,7 @@ package actions
 import (
 	"context"
 
+	actions_model "code.gitea.io/gitea/models/actions"
 	issues_model "code.gitea.io/gitea/models/issues"
 	packages_model "code.gitea.io/gitea/models/packages"
 	perm_model "code.gitea.io/gitea/models/perm"
@@ -762,3 +763,13 @@ 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) {
+	run.Status.IsBlocked()
+	newNotifyInput(repo, sender, webhook_module.HookEventWorkflowRun).WithPayload(&api.WorkflowRunPayload{
+		Action:       "queued",
+		Repo:         convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
+		Organization: nil,
+		Sender:       convert.ToUser(ctx, sender, nil),
+	}).Notify(ctx)
+}
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/notifier.go b/services/webhook/notifier.go
index 7d779cd5275e6..84842ea37438e 100644
--- a/services/webhook/notifier.go
+++ b/services/webhook/notifier.go
@@ -1030,6 +1030,45 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_
 	}
 }
 
+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, conclusion := toActionStatus(run.Status)
+
+	if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowJob, &api.WorkflowRunPayload{
+		Action:   status,
+		Workflow: nil,
+		WorkflowRun: &api.ActionWorkflowRun{
+			ID:        run.ID,
+			RunNumber: run.Index,
+			HTMLURL:   run.HTMLURL(),
+			// Missing api endpoint for this location, artifacts are available under a nested url
+			URL:         fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID),
+			Event:       run.TriggerEvent,
+			RunAttempt:  0,
+			HeadSha:     run.CommitSHA,
+			HeadBranch:  git.RefName(run.Ref).BranchName(),
+			Status:      status,
+			Conclusion:  conclusion,
+			StartedAt:   run.Started.AsTime().UTC(),
+			CompletedAt: run.Stopped.AsTime().UTC(),
+		},
+		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 toActionStatus(status actions_model.Status) (string, string) {
 	var action string
 	var conclusion string
diff --git a/templates/repo/settings/webhook/settings.tmpl b/templates/repo/settings/webhook/settings.tmpl
index 16ad263e42a58..5a69bd737ccfb 100644
--- a/templates/repo/settings/webhook/settings.tmpl
+++ b/templates/repo/settings/webhook/settings.tmpl
@@ -264,6 +264,15 @@
 			<label>{{ctx.Locale.Tr "repo.settings.event_header_workflow"}}</label>
 		</div>
 		<!-- Workflow Job 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>
 		<div class="seven wide column">
 			<div class="field">
 				<div class="ui checkbox">

From 013a0af3853b10c03a472a93db169fd9dacb434c Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 17:27:25 +0100
Subject: [PATCH 02/56] 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 23bdb46d1b3aa..7c8de3d776975 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 bc76b5285e5a2..a89fb6303a318 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 6b4ce37fcf4a2..6463ae50d0143 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 ac2680766c040..674e3f361f4b3 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)

From e93f9d20d1ecc58e02facb0cf802850ef380aa99 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 18:04:44 +0100
Subject: [PATCH 03/56] update comment

---
 templates/repo/settings/webhook/settings.tmpl | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/templates/repo/settings/webhook/settings.tmpl b/templates/repo/settings/webhook/settings.tmpl
index 5a69bd737ccfb..b8d9609391ff0 100644
--- a/templates/repo/settings/webhook/settings.tmpl
+++ b/templates/repo/settings/webhook/settings.tmpl
@@ -263,7 +263,7 @@
 		<div class="fourteen wide column">
 			<label>{{ctx.Locale.Tr "repo.settings.event_header_workflow"}}</label>
 		</div>
-		<!-- Workflow Job Event -->
+		<!-- Workflow Run Event -->
 		<div class="seven wide column">
 			<div class="field">
 				<div class="ui checkbox">
@@ -273,6 +273,7 @@
 				</div>
 			</div>
 		</div>
+		<!-- Workflow Job Event -->
 		<div class="seven wide column">
 			<div class="field">
 				<div class="ui checkbox">

From 6701375e671b6baea257e2320a02c890952efe75 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 19:59:01 +0100
Subject: [PATCH 04/56] add routes

---
 routers/api/v1/api.go | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index a89fb6303a318..2023c94e6ac65 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -1244,8 +1244,10 @@ func Routes() *web.Router {
 				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)

From 988cafe7806333b7ecf6b71f1f9aec8c47b41fe3 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 20:06:19 +0100
Subject: [PATCH 05/56] remove duplicated code

---
 services/webhook/notifier.go | 95 ++++++------------------------------
 1 file changed, 15 insertions(+), 80 deletions(-)

diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go
index 84842ea37438e..ece0e2d8a6aeb 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"
@@ -956,72 +954,17 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_
 		org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
 	}
 
-	err := job.LoadAttributes(ctx)
-	if err != nil {
-		log.Error("Error loading job attributes: %v", err)
-		return
-	}
+	status, _ := toActionStatus(job.Status)
 
-	jobIndex := 0
-	jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID)
+	convertedJob, err := convert.ToActionWorkflowJob(ctx, repo, job)
 	if err != nil {
-		log.Error("Error loading getting run jobs: %v", err)
+		log.Error("ToActionWorkflowJob: %v", err)
 		return
 	}
-	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 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(),
-			})
-		}
-	}
 
 	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(),
-		},
+		Action:       status,
+		WorkflowJob:  convertedJob,
 		Organization: org,
 		Repo:         convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
 		Sender:       convert.ToUser(ctx, sender, nil),
@@ -1041,26 +984,18 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
 		org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
 	}
 
-	status, conclusion := toActionStatus(run.Status)
+	status, _ := toActionStatus(run.Status)
+
+	convertedRun, err := convert.ToActionWorkflowRun(repo, run)
+	if err != nil {
+		log.Error("ToActionWorkflowRun: %v", err)
+		return
+	}
 
 	if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowJob, &api.WorkflowRunPayload{
-		Action:   status,
-		Workflow: nil,
-		WorkflowRun: &api.ActionWorkflowRun{
-			ID:        run.ID,
-			RunNumber: run.Index,
-			HTMLURL:   run.HTMLURL(),
-			// Missing api endpoint for this location, artifacts are available under a nested url
-			URL:         fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID),
-			Event:       run.TriggerEvent,
-			RunAttempt:  0,
-			HeadSha:     run.CommitSHA,
-			HeadBranch:  git.RefName(run.Ref).BranchName(),
-			Status:      status,
-			Conclusion:  conclusion,
-			StartedAt:   run.Started.AsTime().UTC(),
-			CompletedAt: run.Stopped.AsTime().UTC(),
-		},
+		Action:       status,
+		Workflow:     nil,
+		WorkflowRun:  convertedRun,
 		Organization: org,
 		Repo:         convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
 		Sender:       convert.ToUser(ctx, sender, nil),

From 20f5d6ee9580b0030da1c05ea661a7531ec4255c Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 20:08:12 +0100
Subject: [PATCH 06/56] use ToActionsStatus

---
 services/convert/convert.go  |  8 ++++----
 services/webhook/notifier.go | 30 ++----------------------------
 2 files changed, 6 insertions(+), 32 deletions(-)

diff --git a/services/convert/convert.go b/services/convert/convert.go
index 674e3f361f4b3..0a8fa3a4772fb 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -232,7 +232,7 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action
 }
 
 func ToActionWorkflowRun(repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflowRun, error) {
-	status, conclusion := toActionStatus(run.Status)
+	status, conclusion := ToActionsStatus(run.Status)
 	return &api.ActionWorkflowRun{
 		ID:           run.ID,
 		URL:          fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID),
@@ -250,7 +250,7 @@ func ToActionWorkflowRun(repo *repo_model.Repository, run *actions_model.ActionR
 	}, nil
 }
 
-func toActionStatus(status actions_model.Status) (string, string) {
+func ToActionsStatus(status actions_model.Status) (string, string) {
 	var action string
 	var conclusion string
 	switch status {
@@ -294,7 +294,7 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, job *
 		}
 	}
 
-	status, conclusion := toActionStatus(job.Status)
+	status, conclusion := ToActionsStatus(job.Status)
 	var runnerID int64
 	var runnerName string
 	var steps []*api.ActionWorkflowStep
@@ -307,7 +307,7 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, job *
 			runnerName = runner.Name
 		}
 		for i, step := range task.Steps {
-			stepStatus, stepConclusion := toActionStatus(job.Status)
+			stepStatus, stepConclusion := ToActionsStatus(job.Status)
 			steps = append(steps, &api.ActionWorkflowStep{
 				Name:        step.Name,
 				Number:      int64(i),
diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go
index ece0e2d8a6aeb..95ef0d672edd0 100644
--- a/services/webhook/notifier.go
+++ b/services/webhook/notifier.go
@@ -954,7 +954,7 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_
 		org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
 	}
 
-	status, _ := toActionStatus(job.Status)
+	status, _ := convert.ToActionsStatus(job.Status)
 
 	convertedJob, err := convert.ToActionWorkflowJob(ctx, repo, job)
 	if err != nil {
@@ -984,7 +984,7 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
 		org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
 	}
 
-	status, _ := toActionStatus(run.Status)
+	status, _ := convert.ToActionsStatus(run.Status)
 
 	convertedRun, err := convert.ToActionWorkflowRun(repo, run)
 	if err != nil {
@@ -1003,29 +1003,3 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(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
-}

From a178e4be7a8fcad47a3a1d4d0ecf70cfc449f6b6 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 20:13:33 +0100
Subject: [PATCH 07/56] use cached task instance if available

---
 routers/api/v1/repo/action.go | 12 ++++++------
 services/convert/convert.go   | 11 +++++++++--
 services/webhook/notifier.go  |  2 +-
 3 files changed, 16 insertions(+), 9 deletions(-)

diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index 6463ae50d0143..621f337a72c7e 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -1000,9 +1000,9 @@ func GetWorkflowRun(ctx *context.APIContext) {
 	//     "$ref": "#/responses/notFound"
 
 	runID := ctx.PathParamInt64("run")
-	job, _, _ := db.GetByID[actions_model.ActionRun](ctx, runID)
+	job, _, err := db.GetByID[actions_model.ActionRun](ctx, runID)
 
-	if job.RepoID != ctx.Repo.Repository.ID {
+	if err != nil || job.RepoID != ctx.Repo.Repository.ID {
 		ctx.APIError(http.StatusNotFound, util.ErrNotExist)
 	}
 
@@ -1070,7 +1070,7 @@ func GetWorkflowJobs(ctx *context.APIContext) {
 
 	res.Entries = make([]*api.ActionWorkflowJob, len(artifacts))
 	for i := range artifacts {
-		convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, artifacts[i])
+		convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, artifacts[i])
 		if err != nil {
 			ctx.APIErrorInternal(err)
 			return
@@ -1113,13 +1113,13 @@ func GetWorkflowJob(ctx *context.APIContext) {
 	//     "$ref": "#/responses/notFound"
 
 	jobID := ctx.PathParamInt64("job_id")
-	job, _, _ := db.GetByID[actions_model.ActionRunJob](ctx, jobID)
+	job, _, err := db.GetByID[actions_model.ActionRunJob](ctx, jobID)
 
-	if job.RepoID != ctx.Repo.Repository.ID {
+	if err != nil || job.RepoID != ctx.Repo.Repository.ID {
 		ctx.APIError(http.StatusNotFound, util.ErrNotExist)
 	}
 
-	convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, job)
+	convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, job)
 	if err != nil {
 		ctx.APIErrorInternal(err)
 		return
diff --git a/services/convert/convert.go b/services/convert/convert.go
index 0a8fa3a4772fb..de1d445749366 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -276,7 +276,9 @@ func ToActionsStatus(status actions_model.Status) (string, string) {
 	return action, conclusion
 }
 
-func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, job *actions_model.ActionRunJob) (*api.ActionWorkflowJob, error) {
+// 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
@@ -300,7 +302,12 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, job *
 	var steps []*api.ActionWorkflowStep
 
 	if job.TaskID != 0 {
-		task, _, _ := db.GetByID[actions_model.ActionTask](ctx, job.TaskID)
+		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 {
diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go
index 95ef0d672edd0..5c47224b4a794 100644
--- a/services/webhook/notifier.go
+++ b/services/webhook/notifier.go
@@ -956,7 +956,7 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_
 
 	status, _ := convert.ToActionsStatus(job.Status)
 
-	convertedJob, err := convert.ToActionWorkflowJob(ctx, repo, job)
+	convertedJob, err := convert.ToActionWorkflowJob(ctx, repo, task, job)
 	if err != nil {
 		log.Error("ToActionWorkflowJob: %v", err)
 		return

From 295bf45d5cbe6de809260b3bf6908ad95618c6d8 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 20:17:42 +0100
Subject: [PATCH 08/56] cleanup and fix webhook type bug

---
 services/actions/notifier.go | 23 ++++++++++++++++++++---
 services/webhook/notifier.go |  2 +-
 2 files changed, 21 insertions(+), 4 deletions(-)

diff --git a/services/actions/notifier.go b/services/actions/notifier.go
index 1040607a5600b..f708bb8043a1b 100644
--- a/services/actions/notifier.go
+++ b/services/actions/notifier.go
@@ -8,7 +8,9 @@ import (
 
 	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"
+	"code.gitea.io/gitea/models/perm"
 	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"
@@ -765,11 +767,26 @@ func (n *actionsNotifier) MigrateRepository(ctx context.Context, doer, u *user_m
 }
 
 func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
+	var org *api.Organization
+	if repo.Owner.IsOrganization() {
+		org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
+	}
+
+	status, _ := convert.ToActionsStatus(run.Status)
+
+	convertedRun, err := convert.ToActionWorkflowRun(repo, run)
+	if err != nil {
+		log.Error("ToActionWorkflowRun: %v", err)
+		return
+	}
+
 	run.Status.IsBlocked()
 	newNotifyInput(repo, sender, webhook_module.HookEventWorkflowRun).WithPayload(&api.WorkflowRunPayload{
-		Action:       "queued",
-		Repo:         convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
-		Organization: nil,
+		Action:       status,
+		Workflow:     nil,
+		WorkflowRun:  convertedRun,
+		Organization: org,
+		Repo:         convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
 		Sender:       convert.ToUser(ctx, sender, nil),
 	}).Notify(ctx)
 }
diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go
index 5c47224b4a794..c0348243abeea 100644
--- a/services/webhook/notifier.go
+++ b/services/webhook/notifier.go
@@ -992,7 +992,7 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
 		return
 	}
 
-	if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowJob, &api.WorkflowRunPayload{
+	if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowRun, &api.WorkflowRunPayload{
 		Action:       status,
 		Workflow:     nil,
 		WorkflowRun:  convertedRun,

From 392baec50b3e6fe2a33025ea72fa43b0d48898b0 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 20:18:12 +0100
Subject: [PATCH 09/56] remove dead code

---
 services/actions/notifier.go | 1 -
 1 file changed, 1 deletion(-)

diff --git a/services/actions/notifier.go b/services/actions/notifier.go
index f708bb8043a1b..e0462542f2eb5 100644
--- a/services/actions/notifier.go
+++ b/services/actions/notifier.go
@@ -780,7 +780,6 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
 		return
 	}
 
-	run.Status.IsBlocked()
 	newNotifyInput(repo, sender, webhook_module.HookEventWorkflowRun).WithPayload(&api.WorkflowRunPayload{
 		Action:       status,
 		Workflow:     nil,

From fb8a221d7f4d8a57c1fbbd6505d14517545aa918 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 20:40:27 +0100
Subject: [PATCH 10/56] Move GetActionWorkflow to convert

---
 services/actions/workflow.go | 96 +-----------------------------------
 services/convert/convert.go  | 92 ++++++++++++++++++++++++++++++++++
 services/webhook/notifier.go |  3 ++
 3 files changed, 96 insertions(+), 95 deletions(-)

diff --git a/services/actions/workflow.go b/services/actions/workflow.go
index dc8a1dd34924f..1eb82c55703ad 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(
diff --git a/services/convert/convert.go b/services/convert/convert.go
index de1d445749366..e73f9302cae67 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"
@@ -23,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"
@@ -350,6 +353,95 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task
 	}, 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/webhook/notifier.go b/services/webhook/notifier.go
index c0348243abeea..0ebea5dc8d088 100644
--- a/services/webhook/notifier.go
+++ b/services/webhook/notifier.go
@@ -986,6 +986,9 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
 
 	status, _ := convert.ToActionsStatus(run.Status)
 
+	// TODO get gitrepo instance
+	// convertedWorkflow, err := convert.GetActionWorkflow(ctx, nil, nil, run.WorkflowID)
+
 	convertedRun, err := convert.ToActionWorkflowRun(repo, run)
 	if err != nil {
 		log.Error("ToActionWorkflowRun: %v", err)

From ccb9dec6dbafaa2f12bcad07f300f6e2f78fef36 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 21:20:05 +0100
Subject: [PATCH 11/56] fixup

---
 routers/api/v1/repo/action.go | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index 621f337a72c7e..6a60f3ba2d1fb 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -634,7 +634,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
@@ -680,7 +680,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)

From da738eaa74ccb253cfbc207196cd72bac42561fe Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 21:58:29 +0100
Subject: [PATCH 12/56] webhook deliver the workflow field

---
 services/actions/notifier.go | 12 +++++++++++-
 services/webhook/notifier.go | 13 ++++++++++---
 2 files changed, 21 insertions(+), 4 deletions(-)

diff --git a/services/actions/notifier.go b/services/actions/notifier.go
index a639d53262028..57990a4e9978e 100644
--- a/services/actions/notifier.go
+++ b/services/actions/notifier.go
@@ -16,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/log"
 	"code.gitea.io/gitea/modules/repository"
 	"code.gitea.io/gitea/modules/setting"
@@ -774,6 +775,15 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
 
 	status, _ := convert.ToActionsStatus(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)
+
 	convertedRun, err := convert.ToActionWorkflowRun(repo, run)
 	if err != nil {
 		log.Error("ToActionWorkflowRun: %v", err)
@@ -782,7 +792,7 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
 
 	newNotifyInput(repo, sender, webhook_module.HookEventWorkflowRun).WithPayload(&api.WorkflowRunPayload{
 		Action:       status,
-		Workflow:     nil,
+		Workflow:     convertedWorkflow,
 		WorkflowRun:  convertedRun,
 		Organization: org,
 		Repo:         convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go
index 41d9b0725587c..9878dd8c62c03 100644
--- a/services/webhook/notifier.go
+++ b/services/webhook/notifier.go
@@ -16,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"
@@ -986,8 +987,14 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
 
 	status, _ := convert.ToActionsStatus(run.Status)
 
-	// TODO get gitrepo instance
-	// convertedWorkflow, err := convert.GetActionWorkflow(ctx, nil, nil, run.WorkflowID)
+	gitRepo, err := gitrepo.OpenRepository(ctx, repo)
+	if err != nil {
+		log.Error("OpenRepository: %v", err)
+		return
+	}
+	defer gitRepo.Close()
+
+	convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
 
 	convertedRun, err := convert.ToActionWorkflowRun(repo, run)
 	if err != nil {
@@ -997,7 +1004,7 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
 
 	if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowRun, &api.WorkflowRunPayload{
 		Action:       status,
-		Workflow:     nil,
+		Workflow:     convertedWorkflow,
 		WorkflowRun:  convertedRun,
 		Organization: org,
 		Repo:         convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),

From 171efe55c564e29f328a0e40f9f69f63ff0d6a33 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 21:59:18 +0100
Subject: [PATCH 13/56] update context with name

---
 services/actions/notifier.go | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/services/actions/notifier.go b/services/actions/notifier.go
index 57990a4e9978e..258bd80fad9ad 100644
--- a/services/actions/notifier.go
+++ b/services/actions/notifier.go
@@ -768,6 +768,8 @@ func (n *actionsNotifier) MigrateRepository(ctx context.Context, doer, u *user_m
 }
 
 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))

From 605ed19b9bba8fa05353b2c72cefdd367087b223 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 22:15:47 +0100
Subject: [PATCH 14/56] invoke workflow_run

---
 routers/web/repo/actions/view.go    | 10 +++++++++-
 services/actions/clear_tasks.go     |  4 ++++
 services/actions/job_emitter.go     | 13 +++++++++++++
 services/actions/notifier_helper.go |  9 +++++++++
 services/actions/schedule_tasks.go  |  1 +
 services/actions/workflow.go        |  9 +++++++++
 6 files changed, 45 insertions(+), 1 deletion(-)

diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index 41f0d2d0ec249..b04d81a274f97 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -460,6 +460,7 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou
 
 	actions_service.CreateCommitStatus(ctx, job)
 	_ = job.LoadAttributes(ctx)
+	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
@@ -561,7 +562,10 @@ 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]
+		notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
+	}
 	ctx.JSON(http.StatusOK, struct{}{})
 }
 
@@ -607,6 +611,10 @@ func Approve(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]
+		notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
+	}
 
 	ctx.JSON(http.StatusOK, struct{}{})
 }
diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go
index bfa10563bcb32..e68719c4f8d88 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)
+		}
 	}
 }
 
diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go
index c11bb5875f45c..f500604c08618 100644
--- a/services/actions/job_emitter.go
+++ b/services/actions/job_emitter.go
@@ -78,6 +78,19 @@ 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(updatedjobs) > 0 {
+		runUpdated := true
+		run := updatedjobs[0].Run
+		for _, job := range jobs {
+			if !job.Status.IsDone() {
+				runUpdated = false
+				break
+			}
+		}
+		if runUpdated {
+			notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
+		}
+	}
 	return nil
 }
 
diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go
index d179134798267..c4179c0a06df2 100644
--- a/services/actions/notifier_helper.go
+++ b/services/actions/notifier_helper.go
@@ -364,6 +364,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 1eb82c55703ad..7f4df0058dd7d 100644
--- a/services/actions/workflow.go
+++ b/services/actions/workflow.go
@@ -183,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)
 	}

From c3f6f133775fefd8bcf362d8ab4d33bae7e085e3 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 22:23:14 +0100
Subject: [PATCH 15/56] prevent endless workflow_run trigger

---
 services/actions/notifier_helper.go | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go
index c4179c0a06df2..3b07cc688eefc 100644
--- a/services/actions/notifier_helper.go
+++ b/services/actions/notifier_helper.go
@@ -263,6 +263,15 @@ func skipWorkflows(input *notifyInput, commit *git.Commit) bool {
 			}
 		}
 	}
+	if input.Event == webhook_module.HookEventWorkflowRun {
+		wrun, ok := input.Payload.(*api.WorkflowRunPayload)
+		if ok && wrun.WorkflowRun != nil && wrun.WorkflowRun.Event != "workflow_run" {
+			// skip workflow runs triggered by another workflow run
+			// TODO GitHub allows chaining up to 5 of them
+			log.Debug("repo %s: skipped workflow_run because of recursive event", input.Repo.RepoPath())
+			return true
+		}
+	}
 	return false
 }
 

From ca6bbc6c6d6f77e423135221c2bf79713c4eb14a Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 17 Mar 2025 22:43:23 +0100
Subject: [PATCH 16/56] wip

---
 models/webhook/webhook_test.go      | 2 +-
 modules/webhook/type.go             | 1 +
 routers/api/v1/utils/hook.go        | 1 +
 routers/web/repo/setting/webhook.go | 1 +
 services/forms/repo_form.go         | 1 +
 services/webhook/payloader.go       | 2 ++
 6 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go
index 6ff77a380dd8d..d1fe722dd08c4 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/webhook/type.go b/modules/webhook/type.go
index bcf8903b2b130..89c6a4bfe5907 100644
--- a/modules/webhook/type.go
+++ b/modules/webhook/type.go
@@ -68,6 +68,7 @@ func AllEvents() []HookEventType {
 		HookEventRelease,
 		HookEventPackage,
 		HookEventStatus,
+		HookEventWorkflowRun,
 		HookEventWorkflowJob,
 	}
 }
diff --git a/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go
index ce0c1b5097ed0..3b87a8f257cb4 100644
--- a/routers/api/v1/utils/hook.go
+++ b/routers/api/v1/utils/hook.go
@@ -207,6 +207,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/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/forms/repo_form.go b/services/forms/repo_form.go
index 1366d30b1f659..bf7554971d43d 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -237,6 +237,7 @@ type WebhookForm struct {
 	Release                  bool
 	Package                  bool
 	Status                   bool
+	WorkflowRun              bool
 	WorkflowJob              bool
 	Active                   bool
 	BranchFilter             string `binding:"GlobPattern"`
diff --git a/services/webhook/payloader.go b/services/webhook/payloader.go
index adb7243fb14ba..058dd08589794 100644
--- a/services/webhook/payloader.go
+++ b/services/webhook/payloader.go
@@ -81,6 +81,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)
 	}

From 875c7745e44526f6c178e172f5100e1ea2ed5297 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Tue, 18 Mar 2025 20:26:22 +0100
Subject: [PATCH 17/56] wip test and fixes

---
 routers/api/v1/repo/action.go                 |  4 +-
 services/actions/notifier.go                  |  2 +-
 services/convert/convert.go                   |  7 +-
 services/webhook/notifier.go                  |  2 +-
 .../workflow_run_api_check_test.go            | 72 +++++++++++++++++++
 5 files changed, 80 insertions(+), 7 deletions(-)
 create mode 100644 tests/integration/workflow_run_api_check_test.go

diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index 88769fa858ffc..c03f7357100b2 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -962,7 +962,7 @@ func GetWorkflowRuns(ctx *context.APIContext) {
 
 	res.Entries = make([]*api.ActionWorkflowRun, len(runs))
 	for i := range runs {
-		convertedRun, err := convert.ToActionWorkflowRun(ctx.Repo.Repository, runs[i])
+		convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, runs[i])
 		if err != nil {
 			ctx.APIErrorInternal(err)
 			return
@@ -1011,7 +1011,7 @@ func GetWorkflowRun(ctx *context.APIContext) {
 		ctx.APIError(http.StatusNotFound, util.ErrNotExist)
 	}
 
-	convertedArtifact, err := convert.ToActionWorkflowRun(ctx.Repo.Repository, job)
+	convertedArtifact, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, job)
 	if err != nil {
 		ctx.APIErrorInternal(err)
 		return
diff --git a/services/actions/notifier.go b/services/actions/notifier.go
index 258bd80fad9ad..0b96ee06b9e42 100644
--- a/services/actions/notifier.go
+++ b/services/actions/notifier.go
@@ -786,7 +786,7 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
 
 	convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
 
-	convertedRun, err := convert.ToActionWorkflowRun(repo, run)
+	convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run)
 	if err != nil {
 		log.Error("ToActionWorkflowRun: %v", err)
 		return
diff --git a/services/convert/convert.go b/services/convert/convert.go
index e73f9302cae67..f4084884c37ff 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -234,7 +234,8 @@ 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) {
+func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflowRun, error) {
+	run.LoadRepo(ctx)
 	status, conclusion := ToActionsStatus(run.Status)
 	return &api.ActionWorkflowRun{
 		ID:           run.ID,
@@ -243,13 +244,13 @@ func ToActionWorkflowRun(repo *repo_model.Repository, run *actions_model.ActionR
 		RunNumber:    run.Index,
 		StartedAt:    run.Started.AsLocalTime(),
 		CompletedAt:  run.Stopped.AsLocalTime(),
-		Event:        run.TriggerEvent,
+		Event:        string(run.Event),
 		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),
+		Path:         fmt.Sprintf("%s@%s", run.WorkflowID, run.Ref),
 	}, nil
 }
 
diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go
index 9878dd8c62c03..5689748724aee 100644
--- a/services/webhook/notifier.go
+++ b/services/webhook/notifier.go
@@ -996,7 +996,7 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
 
 	convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
 
-	convertedRun, err := convert.ToActionWorkflowRun(repo, run)
+	convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run)
 	if err != nil {
 		log.Error("ToActionWorkflowRun: %v", err)
 		return
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)
+	}
+}

From 22bfd96b9cff00567e496a5b53fb42a2c23dd82b Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Tue, 18 Mar 2025 21:03:33 +0100
Subject: [PATCH 18/56] fix keda scaler compat

---
 modules/structs/repo_actions.go | 28 +++++++++++++++-------------
 services/convert/convert.go     |  1 +
 2 files changed, 16 insertions(+), 13 deletions(-)

diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
index 7c8de3d776975..150302e8b93cc 100644
--- a/modules/structs/repo_actions.go
+++ b/modules/structs/repo_actions.go
@@ -86,19 +86,21 @@ type ActionArtifact struct {
 
 // ActionWorkflowRun represents a WorkflowRun
 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,omitempty"`
-	HeadSha      string `json:"head_sha"`
-	HeadBranch   string `json:"head_branch,omitempty"`
-	Status       string `json:"status"`
-	Conclusion   string `json:"conclusion,omitempty"`
+	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
diff --git a/services/convert/convert.go b/services/convert/convert.go
index f4084884c37ff..bb34d97a34466 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -251,6 +251,7 @@ func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *
 		Status:       status,
 		Conclusion:   conclusion,
 		Path:         fmt.Sprintf("%s@%s", run.WorkflowID, run.Ref),
+		Repository:   ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
 	}, nil
 }
 

From 6c9dae9ff54269e3021bcff1fd2fbe1e00f23d3d Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Tue, 18 Mar 2025 21:07:27 +0100
Subject: [PATCH 19/56] fix api result

---
 modules/structs/repo_actions.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
index 150302e8b93cc..eca13825066de 100644
--- a/modules/structs/repo_actions.go
+++ b/modules/structs/repo_actions.go
@@ -115,7 +115,7 @@ type ActionWorkflowRunsResponse struct {
 
 // ActionArtifactsResponse returns ActionArtifacts
 type ActionWorkflowJobsResponse struct {
-	Entries    []*ActionWorkflowJob `json:"workflow_jobs"`
+	Entries    []*ActionWorkflowJob `json:"jobs"`
 	TotalCount int64                `json:"total_count"`
 }
 

From 5588588d2858ff02f26817cdf0bc6361be906948 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Tue, 18 Mar 2025 21:58:02 +0100
Subject: [PATCH 20/56] add missing translate keys

---
 options/locale/locale_en-US.ini | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 876e135b22f57..7d9f2995bb64f 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -2387,6 +2387,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

From 5a0f4c9869aa0464b524beb9561238130ea6c628 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 12:15:06 +0100
Subject: [PATCH 21/56] add other webhook types

---
 services/webhook/dingtalk.go   |  6 ++++++
 services/webhook/discord.go    |  6 ++++++
 services/webhook/feishu.go     |  6 ++++++
 services/webhook/general.go    | 31 +++++++++++++++++++++++++++++++
 services/webhook/matrix.go     |  6 ++++++
 services/webhook/msteams.go    | 14 ++++++++++++++
 services/webhook/packagist.go  |  4 ++++
 services/webhook/payloader.go  |  5 +++--
 services/webhook/slack.go      |  6 ++++++
 services/webhook/telegram.go   |  6 ++++++
 services/webhook/wechatwork.go |  6 ++++++
 11 files changed, 94 insertions(+), 2 deletions(-)

diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go
index 5afca8d65a3f1..6a6aa2a52bf19 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 0a7eb0b166dc8..0911d1e16ab0f 100644
--- a/services/webhook/discord.go
+++ b/services/webhook/discord.go
@@ -271,6 +271,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 ea75038fafbe9..825c6fe051ba5 100644
--- a/services/webhook/general.go
+++ b/services/webhook/general.go
@@ -325,6 +325,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 f70e235f20e98..3edcf90abd523 100644
--- a/services/webhook/msteams.go
+++ b/services/webhook/msteams.go
@@ -317,6 +317,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/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 058dd08589794..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,8 +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.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)
 

From 6df185423d9dcd440982fb882dfaea1d0f46b8e3 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 12:27:21 +0100
Subject: [PATCH 22/56] ..

---
 services/actions/notifier_helper.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go
index 3b07cc688eefc..cc6b1eb09a4c2 100644
--- a/services/actions/notifier_helper.go
+++ b/services/actions/notifier_helper.go
@@ -265,7 +265,7 @@ func skipWorkflows(input *notifyInput, commit *git.Commit) bool {
 	}
 	if input.Event == webhook_module.HookEventWorkflowRun {
 		wrun, ok := input.Payload.(*api.WorkflowRunPayload)
-		if ok && wrun.WorkflowRun != nil && wrun.WorkflowRun.Event != "workflow_run" {
+		if ok && wrun.WorkflowRun != nil && wrun.WorkflowRun.Event == "workflow_run" {
 			// skip workflow runs triggered by another workflow run
 			// TODO GitHub allows chaining up to 5 of them
 			log.Debug("repo %s: skipped workflow_run because of recursive event", input.Repo.RepoPath())

From a983d34622ad9b557830dc2ce245fb19fa75c891 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 13:09:38 +0100
Subject: [PATCH 23/56] fix workflow_run action event processing

---
 modules/actions/workflows.go    | 46 +++++++++++++++++++++++++++++++++
 services/actions/job_emitter.go |  4 +--
 2 files changed, 48 insertions(+), 2 deletions(-)

diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go
index 0d2b0dd9194d9..5afcab145bc64 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 // registry_package
+		webhook_module.HookEventWorkflowRun:
+		return matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt)
+
 	default:
 		log.Warn("unsupported event %q", triggedEvent)
 		return false
@@ -698,3 +702,45 @@ 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(string(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++
+			}
+		default:
+			log.Warn("package event unsupported condition %q", cond)
+		}
+	}
+	return matchTimes == len(evt.Acts())
+}
diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go
index f500604c08618..c77771d6be89d 100644
--- a/services/actions/job_emitter.go
+++ b/services/actions/job_emitter.go
@@ -78,9 +78,9 @@ 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(updatedjobs) > 0 {
+	if len(jobs) > 0 {
 		runUpdated := true
-		run := updatedjobs[0].Run
+		run := jobs[0].Run
 		for _, job := range jobs {
 			if !job.Status.IsDone() {
 				runUpdated = false

From 8cfb0479837ceec77118f3a209925216b934122d Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 15:48:00 +0100
Subject: [PATCH 24/56] fix lint

---
 modules/actions/workflows.go           |   2 +-
 routers/api/v1/repo/action.go          |   2 -
 services/actions/notifier.go           |   8 +-
 services/convert/convert.go            |   5 +-
 services/webhook/notifier.go           |   4 +
 templates/swagger/v1_json.tmpl         | 254 +++++++++++++++++++++++++
 tests/integration/repo_webhook_test.go |   4 +-
 7 files changed, 270 insertions(+), 9 deletions(-)

diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go
index 5afcab145bc64..70d8de468737e 100644
--- a/modules/actions/workflows.go
+++ b/modules/actions/workflows.go
@@ -716,7 +716,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
 		case "types":
 			action := payload.Action
 			for _, val := range vals {
-				if glob.MustCompile(val, '/').Match(string(action)) {
+				if glob.MustCompile(val, '/').Match(action) {
 					matchTimes++
 					break
 				}
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index c03f7357100b2..d2abab9577aa5 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -1017,7 +1017,6 @@ func GetWorkflowRun(ctx *context.APIContext) {
 		return
 	}
 	ctx.JSON(http.StatusOK, convertedArtifact)
-	return
 }
 
 // GetWorkflowJobs Lists all jobs for a workflow run.
@@ -1130,7 +1129,6 @@ func GetWorkflowJob(ctx *context.APIContext) {
 		return
 	}
 	ctx.JSON(http.StatusOK, convertedWorkflowJob)
-	return
 }
 
 // GetArtifacts Lists all artifacts for a repository.
diff --git a/services/actions/notifier.go b/services/actions/notifier.go
index 0b96ee06b9e42..a7ae52d4f3b21 100644
--- a/services/actions/notifier.go
+++ b/services/actions/notifier.go
@@ -10,7 +10,6 @@ import (
 	issues_model "code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/models/organization"
 	packages_model "code.gitea.io/gitea/models/packages"
-	"code.gitea.io/gitea/models/perm"
 	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"
@@ -785,7 +784,10 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
 	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)
@@ -797,7 +799,7 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
 		Workflow:     convertedWorkflow,
 		WorkflowRun:  convertedRun,
 		Organization: org,
-		Repo:         convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
+		Repo:         convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
 		Sender:       convert.ToUser(ctx, sender, nil),
 	}).Notify(ctx)
 }
diff --git a/services/convert/convert.go b/services/convert/convert.go
index bb34d97a34466..9fcda5f46fe4c 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -235,7 +235,10 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action
 }
 
 func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflowRun, error) {
-	run.LoadRepo(ctx)
+	err := run.LoadRepo(ctx)
+	if err != nil {
+		return nil, err
+	}
 	status, conclusion := ToActionsStatus(run.Status)
 	return &api.ActionWorkflowRun{
 		ID:           run.ID,
diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go
index 5689748724aee..cc40dd3377bf2 100644
--- a/services/webhook/notifier.go
+++ b/services/webhook/notifier.go
@@ -995,6 +995,10 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
 	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 {
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 1efaf1a875ab4..e93add76428a0 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/runners/registration-token": {
       "get": {
         "produces": [
@@ -4220,6 +4266,104 @@
         }
       }
     },
+    "/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": "integer",
+            "description": "runid of the workflow run",
+            "name": "run",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the artifact",
+            "name": "name",
+            "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": [
@@ -4272,6 +4416,58 @@
         }
       }
     },
+    "/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
+          },
+          {
+            "type": "string",
+            "description": "name of the artifact",
+            "name": "name",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/ArtifactsList"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/actions/secrets": {
       "get": {
         "produces": [
@@ -19404,19 +19600,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 7e85c10d4b709..4dfe1945c310c 100644
--- a/tests/integration/repo_webhook_test.go
+++ b/tests/integration/repo_webhook_test.go
@@ -707,7 +707,7 @@ jobs:
 		assert.EqualValues(t, commitID, payloads[3].WorkflowJob.HeadSha)
 		assert.EqualValues(t, "repo1", payloads[3].Repo.Name)
 		assert.EqualValues(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, fmt.Sprintf("/actions/jobs/%d", payloads[3].WorkflowJob.ID))
 		assert.Contains(t, payloads[3].WorkflowJob.URL, payloads[3].WorkflowJob.RunURL)
 		assert.Contains(t, payloads[3].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 0))
 		assert.Len(t, payloads[3].WorkflowJob.Steps, 1)
@@ -745,7 +745,7 @@ jobs:
 		assert.EqualValues(t, commitID, payloads[6].WorkflowJob.HeadSha)
 		assert.EqualValues(t, "repo1", payloads[6].Repo.Name)
 		assert.EqualValues(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, fmt.Sprintf("/actions/jobs/%d", payloads[6].WorkflowJob.ID))
 		assert.Contains(t, payloads[6].WorkflowJob.URL, payloads[6].WorkflowJob.RunURL)
 		assert.Contains(t, payloads[6].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 1))
 		assert.Len(t, payloads[6].WorkflowJob.Steps, 2)

From 8fd54f285ab73ec824a29a44e50b4ddc1a25f950 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 15:52:41 +0100
Subject: [PATCH 25/56] update swagger docu

---
 routers/api/v1/repo/action.go  | 24 ++++++++++++------------
 templates/swagger/v1_json.tmpl | 25 ++++++++++++-------------
 2 files changed, 24 insertions(+), 25 deletions(-)

diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index d2abab9577aa5..b8ccdb0a310e5 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -912,14 +912,19 @@ func GetWorkflowRuns(ctx *context.APIContext) {
 	//   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
+	// - name: event
 	//   in: query
-	//   description: name of the artifact
+	//   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:
@@ -1042,11 +1047,6 @@ func GetWorkflowJobs(ctx *context.APIContext) {
 	//   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"
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index e93add76428a0..9ee75770e1042 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -4292,16 +4292,21 @@
             "required": true
           },
           {
-            "type": "integer",
-            "description": "runid of the workflow run",
-            "name": "run",
-            "in": "path",
-            "required": true
+            "type": "string",
+            "description": "workflow event name",
+            "name": "event",
+            "in": "query"
           },
           {
             "type": "string",
-            "description": "name of the artifact",
-            "name": "name",
+            "description": "workflow branch",
+            "name": "branch",
+            "in": "query"
+          },
+          {
+            "type": "string",
+            "description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
+            "name": "status",
             "in": "query"
           }
         ],
@@ -4447,12 +4452,6 @@
             "name": "run",
             "in": "path",
             "required": true
-          },
-          {
-            "type": "string",
-            "description": "name of the artifact",
-            "name": "name",
-            "in": "query"
           }
         ],
         "responses": {

From c43cb79f957c5861bd4f82c3dc8bc13383121178 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 16:21:37 +0100
Subject: [PATCH 26/56] ...

---
 tests/integration/repo_webhook_test.go | 2 --
 1 file changed, 2 deletions(-)

diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go
index 4dfe1945c310c..35b5d6ddf7540 100644
--- a/tests/integration/repo_webhook_test.go
+++ b/tests/integration/repo_webhook_test.go
@@ -708,7 +708,6 @@ jobs:
 		assert.EqualValues(t, "repo1", payloads[3].Repo.Name)
 		assert.EqualValues(t, "user2/repo1", payloads[3].Repo.FullName)
 		assert.Contains(t, payloads[3].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[3].WorkflowJob.ID))
-		assert.Contains(t, payloads[3].WorkflowJob.URL, payloads[3].WorkflowJob.RunURL)
 		assert.Contains(t, payloads[3].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 0))
 		assert.Len(t, payloads[3].WorkflowJob.Steps, 1)
 
@@ -746,7 +745,6 @@ jobs:
 		assert.EqualValues(t, "repo1", payloads[6].Repo.Name)
 		assert.EqualValues(t, "user2/repo1", payloads[6].Repo.FullName)
 		assert.Contains(t, payloads[6].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[6].WorkflowJob.ID))
-		assert.Contains(t, payloads[6].WorkflowJob.URL, payloads[6].WorkflowJob.RunURL)
 		assert.Contains(t, payloads[6].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 1))
 		assert.Len(t, payloads[6].WorkflowJob.Steps, 2)
 	})

From 23de934a1ece39982450875528aac22356b29553 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 18:34:59 +0100
Subject: [PATCH 27/56] add branches-ignore to workflow_run

---
 modules/actions/workflows.go | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go
index 70d8de468737e..9279da9ef0bf6 100644
--- a/modules/actions/workflows.go
+++ b/modules/actions/workflows.go
@@ -738,6 +738,14 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
 			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("package event unsupported condition %q", cond)
 		}

From 956556dc20c5b63ec79ef0613bb7ca6c88b74699 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 18:36:56 +0100
Subject: [PATCH 28/56] update comment

---
 modules/actions/workflows.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go
index 9279da9ef0bf6..760a85b2da11f 100644
--- a/modules/actions/workflows.go
+++ b/modules/actions/workflows.go
@@ -243,7 +243,7 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
 		webhook_module.HookEventPackage:
 		return matchPackageEvent(payload.(*api.PackagePayload), evt)
 
-	case // registry_package
+	case // workflow_run
 		webhook_module.HookEventWorkflowRun:
 		return matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt)
 

From bb85519b06d0d5b5845a764cb2de1aa96457f3d8 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 18:48:30 +0100
Subject: [PATCH 29/56] allow workflow_run for recusive depth of 5

---
 models/actions/run.go               | 11 +++++++++++
 services/actions/notifier_helper.go | 23 ++++++++++++++++-------
 2 files changed, 27 insertions(+), 7 deletions(-)

diff --git a/models/actions/run.go b/models/actions/run.go
index 89f7f3e64031d..1e9f046c8d8f3 100644
--- a/models/actions/run.go
+++ b/models/actions/run.go
@@ -164,6 +164,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/services/actions/notifier_helper.go b/services/actions/notifier_helper.go
index cc6b1eb09a4c2..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{
@@ -265,12 +265,21 @@ func skipWorkflows(input *notifyInput, commit *git.Commit) bool {
 	}
 	if input.Event == webhook_module.HookEventWorkflowRun {
 		wrun, ok := input.Payload.(*api.WorkflowRunPayload)
-		if ok && wrun.WorkflowRun != nil && wrun.WorkflowRun.Event == "workflow_run" {
-			// skip workflow runs triggered by another workflow run
-			// TODO GitHub allows chaining up to 5 of them
-			log.Debug("repo %s: skipped workflow_run because of recursive event", input.Repo.RepoPath())
-			return true
+		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
 }

From cdefda13a3b746bbe21d41355be13915bf08b42a Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 18:54:29 +0100
Subject: [PATCH 30/56] fix comment

---
 modules/actions/workflows.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go
index 760a85b2da11f..749977739e98b 100644
--- a/modules/actions/workflows.go
+++ b/modules/actions/workflows.go
@@ -747,7 +747,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
 				matchTimes++
 			}
 		default:
-			log.Warn("package event unsupported condition %q", cond)
+			log.Warn("workflow run event unsupported condition %q", cond)
 		}
 	}
 	return matchTimes == len(evt.Acts())

From d404c60b73490936e0339065ac2061b4dfb9168e Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 19:33:17 +0100
Subject: [PATCH 31/56] Sync run status with db prior webhook delivery

* fixes status glitch of webhook
* e.g. queued for cancel
* e.g. completed for rerun
---
 routers/web/repo/actions/view.go | 17 +++++++++++++----
 services/actions/clear_tasks.go  |  6 +++---
 services/actions/job_emitter.go  |  5 ++++-
 3 files changed, 20 insertions(+), 8 deletions(-)

diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index b04d81a274f97..30d16b8dc9c35 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -459,6 +459,8 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou
 	}
 
 	actions_service.CreateCommitStatus(ctx, job)
+	// Sync run status with db
+	job.Run = nil
 	_ = job.LoadAttributes(ctx)
 	notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
 	notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
@@ -564,6 +566,9 @@ func Cancel(ctx *context_module.Context) {
 	}
 	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)
 	}
 	ctx.JSON(http.StatusOK, struct{}{})
@@ -607,15 +612,19 @@ func Approve(ctx *context_module.Context) {
 
 	actions_service.CreateCommitStatus(ctx, jobs...)
 
-	for _, job := range updatedjobs {
-		_ = 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
+		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)
+	}
+
 	ctx.JSON(http.StatusOK, struct{}{})
 }
 
diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go
index e68719c4f8d88..d74e3f43e2fd5 100644
--- a/services/actions/clear_tasks.go
+++ b/services/actions/clear_tasks.go
@@ -127,11 +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)
-			if job.Run != nil {
-				notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
-			}
+			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 c77771d6be89d..6504c8e1bb64e 100644
--- a/services/actions/job_emitter.go
+++ b/services/actions/job_emitter.go
@@ -80,7 +80,6 @@ func checkJobsOfRun(ctx context.Context, runID int64) error {
 	}
 	if len(jobs) > 0 {
 		runUpdated := true
-		run := jobs[0].Run
 		for _, job := range jobs {
 			if !job.Status.IsDone() {
 				runUpdated = false
@@ -88,6 +87,10 @@ func checkJobsOfRun(ctx context.Context, runID int64) error {
 			}
 		}
 		if runUpdated {
+			// Sync run status with db
+			jobs[0].Run = nil
+			jobs[0].LoadAttributes(ctx)
+			run := jobs[0].Run
 			notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
 		}
 	}

From 0940208a00f22bb0374cb0457e3e1cca6eb16594 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 21:35:22 +0100
Subject: [PATCH 32/56] fix lint

---
 routers/web/repo/actions/view.go | 9 +++++++--
 services/actions/job_emitter.go  | 4 +++-
 2 files changed, 10 insertions(+), 3 deletions(-)

diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index 30d16b8dc9c35..d634a49bb9bbd 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -461,7 +461,9 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou
 	actions_service.CreateCommitStatus(ctx, job)
 	// Sync run status with db
 	job.Run = nil
-	_ = job.LoadAttributes(ctx)
+	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)
 
@@ -568,7 +570,10 @@ func Cancel(ctx *context_module.Context) {
 		job := updatedjobs[0]
 		// Sync run status with db
 		job.Run = nil
-		job.LoadAttributes(ctx)
+		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{}{})
diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go
index 6504c8e1bb64e..e0cf1136f2f0a 100644
--- a/services/actions/job_emitter.go
+++ b/services/actions/job_emitter.go
@@ -89,7 +89,9 @@ func checkJobsOfRun(ctx context.Context, runID int64) error {
 		if runUpdated {
 			// Sync run status with db
 			jobs[0].Run = nil
-			jobs[0].LoadAttributes(ctx)
+			if err := jobs[0].LoadAttributes(ctx); err != nil {
+				return err
+			}
 			run := jobs[0].Run
 			notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
 		}

From 4629a68229833fa469fb44344fb472eeaeea4f83 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 22:06:54 +0100
Subject: [PATCH 33/56] fix comment

---
 routers/api/v1/repo/action.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index b8ccdb0a310e5..7af852c4f952f 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -894,7 +894,7 @@ func convertToInternal(s string) actions_model.Status {
 	}
 }
 
-// GetArtifacts Lists all artifacts for a repository.
+// GetWorkflowRuns Lists all runs for a repository run.
 func GetWorkflowRuns(ctx *context.APIContext) {
 	// swagger:operation GET /repos/{owner}/{repo}/actions/runs repository getWorkflowRuns
 	// ---

From 9b3eb4c180367be8945d97646e9ac5755f17f5ab Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Fri, 21 Mar 2025 22:13:39 +0100
Subject: [PATCH 34/56] change action of workflow_run to align

---
 services/actions/notifier.go |  2 +-
 services/convert/convert.go  | 14 ++++++++++++++
 services/webhook/notifier.go |  2 +-
 3 files changed, 16 insertions(+), 2 deletions(-)

diff --git a/services/actions/notifier.go b/services/actions/notifier.go
index a7ae52d4f3b21..1039d48cbda38 100644
--- a/services/actions/notifier.go
+++ b/services/actions/notifier.go
@@ -774,7 +774,7 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
 		org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
 	}
 
-	status, _ := convert.ToActionsStatus(run.Status)
+	status := convert.ToWorkflowRunAction(run.Status)
 
 	gitRepo, err := gitrepo.OpenRepository(context.Background(), repo)
 	if err != nil {
diff --git a/services/convert/convert.go b/services/convert/convert.go
index 9fcda5f46fe4c..0ce6bf15d39ef 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -258,6 +258,20 @@ func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *
 	}, 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
diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go
index cc40dd3377bf2..dc44460860081 100644
--- a/services/webhook/notifier.go
+++ b/services/webhook/notifier.go
@@ -985,7 +985,7 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
 		org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
 	}
 
-	status, _ := convert.ToActionsStatus(run.Status)
+	status := convert.ToWorkflowRunAction(run.Status)
 
 	gitRepo, err := gitrepo.OpenRepository(ctx, repo)
 	if err != nil {

From 5beb9ae0db32ddb8b4f84909149e2c5711fbb473 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 22 Mar 2025 00:25:08 +0100
Subject: [PATCH 35/56] ...

---
 routers/web/repo/actions/view.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index d634a49bb9bbd..531edd89619a3 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -621,7 +621,7 @@ func Approve(ctx *context_module.Context) {
 		job := updatedjobs[0]
 		// Sync run status with db
 		job.Run = nil
-		job.LoadAttributes(ctx)
+		_ = job.LoadAttributes(ctx)
 		notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
 	}
 

From 7a0bb0325bc7bd0bf998515303296664d9599007 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 12 Apr 2025 23:43:58 +0200
Subject: [PATCH 36/56] reorder api

---
 routers/api/v1/api.go | 14 +++++++++-----
 1 file changed, 9 insertions(+), 5 deletions(-)

diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 38464dcbd6b02..7609ca7243590 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -1169,6 +1169,7 @@ func Routes() *web.Router {
 				}, context.ReferencesGitRepo(), reqToken(), reqRepoReader(unit.TypeActions))
 
 				m.Group("/actions/jobs", func() {
+					m.Get("/{job_id}", repo.GetWorkflowJob)
 					m.Get("/{job_id}/logs", repo.DownloadActionsRunJobLogs)
 				}, reqToken(), reqRepoReader(unit.TypeActions))
 
@@ -1247,11 +1248,14 @@ 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.Group("/runs", func() {
+						m.Get("", repo.GetWorkflowRuns)
+						m.Group("/{run}", func() {
+							m.Get("", repo.GetWorkflowRun)
+							m.Get("/jobs", repo.GetWorkflowJobs)
+							m.Get("/artifacts", repo.GetArtifactsOfRun)
+						})
+					})
 					m.Get("/artifacts", repo.GetArtifacts)
 					m.Group("/artifacts/{artifact_id}", func() {
 						m.Get("", repo.GetArtifact)

From 896b24aac6d6bff828862113e7aa64e1c506c6e4 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 12 Apr 2025 23:44:19 +0200
Subject: [PATCH 37/56] fix lint

---
 services/webhook/general.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/services/webhook/general.go b/services/webhook/general.go
index bcb8eb14a0d34..be457e46f5f7a 100644
--- a/services/webhook/general.go
+++ b/services/webhook/general.go
@@ -352,7 +352,7 @@ func getWorkflowRunPayloadInfo(p *api.WorkflowRunPayload, linkFormatter linkForm
 		color = greyColor
 	}
 	if withSender {
-		text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName))
+		text += " by " + linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName)
 	}
 
 	return text, color

From 37c96b05c5c16d60ed34793ba13950144cff952e Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 01:07:54 +0200
Subject: [PATCH 38/56] fix workflow name in workflow payload

* add workflow_run action trigger + webhook test
* loads the workflow and fill the name key if present
---
 models/fixtures/action_run.yml                |  20 ++
 models/fixtures/action_run_job.yml            |  16 ++
 services/convert/convert.go                   |  22 +-
 tests/integration/actions_trigger_test.go     |  14 +-
 tests/integration/repo_webhook_test.go        | 201 ++++++++++++++++++
 .../workflow_run_api_check_test.go            |  52 ++---
 6 files changed, 281 insertions(+), 44 deletions(-)

diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml
index 1db849352f280..6f18da360aab9 100644
--- a/models/fixtures/action_run.yml
+++ b/models/fixtures/action_run.yml
@@ -74,3 +74,23 @@
   updated: 1683636626
   need_approval: 0
   approved_by: 0
+
+-
+  id: 802
+  title: "workflow run list"
+  repo_id: 4
+  owner_id: 1
+  workflow_id: "test.yaml"
+  index: 191
+  trigger_user_id: 1
+  ref: "refs/heads/test"
+  commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
+  event: "push"
+  is_fork_pull_request: 0
+  status: 1
+  started: 1683636528
+  stopped: 1683636626
+  created: 1683636108
+  updated: 1683636626
+  need_approval: 0
+  approved_by: 0
diff --git a/models/fixtures/action_run_job.yml b/models/fixtures/action_run_job.yml
index 8837e6ec2d80d..eaad7779e7943 100644
--- a/models/fixtures/action_run_job.yml
+++ b/models/fixtures/action_run_job.yml
@@ -69,3 +69,19 @@
   status: 5
   started: 1683636528
   stopped: 1683636626
+
+-
+  id: 203
+  run_id: 802
+  repo_id: 4
+  owner_id: 1
+  commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
+  is_fork_pull_request: 0
+  name: job2
+  attempt: 1
+  job_id: job2
+  needs: '["job1"]'
+  task_id: 51
+  status: 5
+  started: 1683636528
+  stopped: 1683636626
\ No newline at end of file
diff --git a/services/convert/convert.go b/services/convert/convert.go
index 6108834f99906..aa0ccb7dd19b6 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -5,6 +5,7 @@
 package convert
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"net/url"
@@ -34,6 +35,7 @@ import (
 	"code.gitea.io/gitea/modules/util"
 	asymkey_service "code.gitea.io/gitea/services/asymkey"
 	"code.gitea.io/gitea/services/gitdiff"
+	"github.com/nektos/act/pkg/model"
 
 	runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
 )
@@ -423,9 +425,25 @@ func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, co
 	createdAt := commit.Author.When
 	updatedAt := commit.Author.When
 
+	content, err := actions.GetContentFromEntry(entry)
+	name := entry.Name()
+	if err == nil {
+		workflow, err := model.ReadWorkflow(bytes.NewReader(content))
+		if err == nil {
+			// Only use the name when specified in the workflow file
+			if workflow.Name != "" {
+				name = workflow.Name
+			}
+		} else {
+			log.Error("getActionWorkflowEntry: Failed to parse workflow: %v", err)
+		}
+	} else {
+		log.Error("getActionWorkflowEntry: Failed to get content from entry: %v", err)
+	}
+
 	return &api.ActionWorkflow{
 		ID:        entry.Name(),
-		Name:      entry.Name(),
+		Name:      name,
 		Path:      path.Join(folder, entry.Name()),
 		State:     state,
 		CreatedAt: createdAt,
@@ -464,7 +482,7 @@ func GetActionWorkflow(ctx context.Context, gitrepo *git.Repository, repo *repo_
 	}
 
 	for _, entry := range entries {
-		if entry.Name == workflowID {
+		if entry.ID == workflowID {
 			return entry, nil
 		}
 	}
diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go
index f576dc38abf7f..081a8abdee3a0 100644
--- a/tests/integration/actions_trigger_test.go
+++ b/tests/integration/actions_trigger_test.go
@@ -719,7 +719,7 @@ func TestWorkflowDispatchPublicApi(t *testing.T) {
 				{
 					Operation: "create",
 					TreePath:  ".gitea/workflows/dispatch.yml",
-					ContentReader: strings.NewReader(`name: test
+					ContentReader: strings.NewReader(`
 on:
   workflow_dispatch
 jobs:
@@ -799,7 +799,7 @@ func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) {
 				{
 					Operation: "create",
 					TreePath:  ".gitea/workflows/dispatch.yml",
-					ContentReader: strings.NewReader(`name: test
+					ContentReader: strings.NewReader(`
 on:
   workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
 jobs:
@@ -890,7 +890,7 @@ func TestWorkflowDispatchPublicApiJSON(t *testing.T) {
 				{
 					Operation: "create",
 					TreePath:  ".gitea/workflows/dispatch.yml",
-					ContentReader: strings.NewReader(`name: test
+					ContentReader: strings.NewReader(`
 on:
   workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
 jobs:
@@ -976,7 +976,7 @@ func TestWorkflowDispatchPublicApiWithInputsJSON(t *testing.T) {
 				{
 					Operation: "create",
 					TreePath:  ".gitea/workflows/dispatch.yml",
-					ContentReader: strings.NewReader(`name: test
+					ContentReader: strings.NewReader(`
 on:
   workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
 jobs:
@@ -1070,7 +1070,7 @@ func TestWorkflowDispatchPublicApiWithInputsNonDefaultBranchJSON(t *testing.T) {
 				{
 					Operation: "create",
 					TreePath:  ".gitea/workflows/dispatch.yml",
-					ContentReader: strings.NewReader(`name: test
+					ContentReader: strings.NewReader(`
 on:
   workflow_dispatch
 jobs:
@@ -1106,7 +1106,7 @@ jobs:
 				{
 					Operation: "update",
 					TreePath:  ".gitea/workflows/dispatch.yml",
-					ContentReader: strings.NewReader(`name: test
+					ContentReader: strings.NewReader(`
 on:
   workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
 jobs:
@@ -1207,7 +1207,7 @@ func TestWorkflowApi(t *testing.T) {
 				{
 					Operation: "create",
 					TreePath:  ".gitea/workflows/dispatch.yml",
-					ContentReader: strings.NewReader(`name: test
+					ContentReader: strings.NewReader(`
 on:
   workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
 jobs:
diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go
index 6d8f9a790aeac..7a14599e26bd3 100644
--- a/tests/integration/repo_webhook_test.go
+++ b/tests/integration/repo_webhook_test.go
@@ -749,3 +749,204 @@ jobs:
 		assert.Len(t, payloads[6].WorkflowJob.Steps, 2)
 	})
 }
+
+type workflowRunWebhook struct {
+	URL            string
+	payloads       []api.WorkflowRunPayload
+	triggeredEvent string
+}
+
+func Test_WebhookWorkflowRun(t *testing.T) {
+	webhookData := &workflowRunWebhook{}
+	provider := newMockWebhookProvider(func(r *http.Request) {
+		assert.Contains(t, r.Header["X-Github-Event-Type"], "workflow_run", "X-GitHub-Event-Type should contain workflow_run")
+		assert.Contains(t, r.Header["X-Gitea-Event-Type"], "workflow_run", "X-Gitea-Event-Type should contain workflow_run")
+		assert.Contains(t, r.Header["X-Gogs-Event-Type"], "workflow_run", "X-Gogs-Event-Type should contain workflow_run")
+		content, _ := io.ReadAll(r.Body)
+		var payload api.WorkflowRunPayload
+		err := json.Unmarshal(content, &payload)
+		assert.NoError(t, err)
+		webhookData.payloads = append(webhookData.payloads, payload)
+		webhookData.triggeredEvent = "workflow_run"
+	}, http.StatusOK)
+	defer provider.Close()
+	webhookData.URL = provider.URL()
+
+	tests := []struct {
+		name     string
+		callback func(t *testing.T, webhookData *workflowRunWebhook)
+	}{
+		{
+			name:     "WorkflowRun",
+			callback: testWebhookWorkflowRun,
+		},
+		{
+			name:     "WorkflowRunDepthLimit",
+			callback: testWebhookWorkflowRunDepthLimit,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			webhookData.payloads = nil
+			webhookData.triggeredEvent = ""
+			onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+				test.callback(t, webhookData)
+			})
+		})
+	}
+}
+
+func testWebhookWorkflowRun(t *testing.T, webhookData *workflowRunWebhook) {
+	// 1. create a new webhook with special webhook for repo1
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	session := loginUser(t, "user2")
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+	testAPICreateWebhookForRepo(t, session, "user2", "repo1", webhookData.URL, "workflow_run")
+
+	repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
+
+	gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1)
+	assert.NoError(t, err)
+
+	runner := newMockRunner()
+	runner.registerAsRepoRunner(t, "user2", "repo1", "mock-runner", []string{"ubuntu-latest"}, false)
+
+	// 2.1 add workflow_run workflow file to the repo
+
+	opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+"dispatch.yml", `
+on:
+  workflow_run:
+    workflows: ["Push"]
+    types:
+    - completed
+jobs:
+  dispatch:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo 'test the webhook'
+`)
+	createWorkflowFile(t, token, "user2", "repo1", ".gitea/workflows/dispatch.yml", opts)
+
+	// 2.2 trigger the webhooks
+
+	// add workflow file to the repo
+	// init the workflow
+	wfTreePath := ".gitea/workflows/push.yml"
+	wfFileContent := `name: Push
+on: push
+jobs:
+  wf1-job:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo 'test the webhook'
+  wf2-job:
+    runs-on: ubuntu-latest
+    needs: wf1-job
+    steps:
+      - run: echo 'cmd 1'
+      - run: echo 'cmd 2'
+`
+	opts = getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent)
+	createWorkflowFile(t, token, "user2", "repo1", wfTreePath, opts)
+
+	commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch)
+	assert.NoError(t, err)
+
+	// 3. validate the webhook is triggered
+	assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
+	assert.Len(t, webhookData.payloads, 1)
+	assert.Equal(t, "requested", webhookData.payloads[0].Action)
+	assert.Equal(t, "queued", webhookData.payloads[0].WorkflowRun.Status)
+	assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[0].WorkflowRun.HeadBranch)
+	assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha)
+	assert.Equal(t, "repo1", webhookData.payloads[0].Repo.Name)
+	assert.Equal(t, "user2/repo1", webhookData.payloads[0].Repo.FullName)
+
+	// 4. Execute two Jobs
+	task := runner.fetchTask(t)
+	outcome := &mockTaskOutcome{
+		result:   runnerv1.Result_RESULT_SUCCESS,
+		execTime: time.Millisecond,
+	}
+	runner.execTask(t, task, outcome)
+
+	task = runner.fetchTask(t)
+	outcome = &mockTaskOutcome{
+		result:   runnerv1.Result_RESULT_FAILURE,
+		execTime: time.Millisecond,
+	}
+	runner.execTask(t, task, outcome)
+
+	// 7. validate the webhook is triggered
+	assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
+	assert.Len(t, webhookData.payloads, 3)
+	assert.Equal(t, "completed", webhookData.payloads[1].Action)
+	assert.Equal(t, "push", webhookData.payloads[1].WorkflowRun.Event)
+
+	// 3. validate the webhook is triggered
+	assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
+	assert.Len(t, webhookData.payloads, 3)
+	assert.Equal(t, "requested", webhookData.payloads[2].Action)
+	assert.Equal(t, "queued", webhookData.payloads[2].WorkflowRun.Status)
+	assert.Equal(t, "workflow_run", webhookData.payloads[2].WorkflowRun.Event)
+	assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[2].WorkflowRun.HeadBranch)
+	assert.Equal(t, commitID, webhookData.payloads[2].WorkflowRun.HeadSha)
+	assert.Equal(t, "repo1", webhookData.payloads[2].Repo.Name)
+	assert.Equal(t, "user2/repo1", webhookData.payloads[2].Repo.FullName)
+}
+
+func testWebhookWorkflowRunDepthLimit(t *testing.T, webhookData *workflowRunWebhook) {
+	// 1. create a new webhook with special webhook for repo1
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	session := loginUser(t, "user2")
+	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+	testAPICreateWebhookForRepo(t, session, "user2", "repo1", webhookData.URL, "workflow_run")
+
+	repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
+
+	gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1)
+	assert.NoError(t, err)
+
+	// 2. trigger the webhooks
+
+	// add workflow file to the repo
+	// init the workflow
+	wfTreePath := ".gitea/workflows/push.yml"
+	wfFileContent := `name: Endless Loop
+on:
+  push:
+  workflow_run:
+    types:
+    - requested
+jobs:
+  dispatch:
+    runs-on: ubuntu-latest
+    steps:
+      - run: echo 'test the webhook'
+`
+	opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent)
+	createWorkflowFile(t, token, "user2", "repo1", wfTreePath, opts)
+
+	commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch)
+	assert.NoError(t, err)
+
+	// 3. validate the webhook is triggered
+	assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
+	// 1x push + 5x workflow_run requested chain
+	assert.Len(t, webhookData.payloads, 6)
+	for i := 0; i < 6; i++ {
+		assert.Equal(t, "requested", webhookData.payloads[i].Action)
+		assert.Equal(t, "queued", webhookData.payloads[i].WorkflowRun.Status)
+		assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[i].WorkflowRun.HeadBranch)
+		assert.Equal(t, commitID, webhookData.payloads[i].WorkflowRun.HeadSha)
+		if i == 0 {
+			assert.Equal(t, "push", webhookData.payloads[i].WorkflowRun.Event)
+		} else {
+			assert.Equal(t, "workflow_run", webhookData.payloads[i].WorkflowRun.Event)
+		}
+		assert.Equal(t, "repo1", webhookData.payloads[i].Repo.Name)
+		assert.Equal(t, "user2/repo1", webhookData.payloads[i].Repo.FullName)
+	}
+}
diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go
index f142da7b226af..f43ce717c7746 100644
--- a/tests/integration/workflow_run_api_check_test.go
+++ b/tests/integration/workflow_run_api_check_test.go
@@ -25,7 +25,9 @@ func TestAPIWorkflowRunRepoApi(t *testing.T) {
 	runnerList := api.ActionWorkflowRunsResponse{}
 	DecodeJSON(t, runnerListResp, &runnerList)
 
-	assert.Len(t, runnerList.Entries, 4)
+	assert.Len(t, runnerList.Entries, 5)
+
+	foundRun := false
 
 	for _, run := range runnerList.Entries {
 		req := NewRequest(t, "GET", fmt.Sprintf("%s/%s", run.URL, "jobs")).AddTokenAuth(token)
@@ -33,40 +35,20 @@ func TestAPIWorkflowRunRepoApi(t *testing.T) {
 		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)
+		if run.ID == 802 {
+			foundRun = true
+			assert.Len(t, jobList.Entries, 1)
+			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)
 	}
+	assert.True(t, foundRun, "Expected to find run with ID 802")
 }

From 8a7f930d0b6ddbc91a65dafa6334f9222ff0770c Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 02:03:06 +0200
Subject: [PATCH 39/56] fixup

---
 models/fixtures/action_run.yml     | 1 -
 models/fixtures/action_run_job.yml | 3 +--
 services/convert/convert.go        | 2 +-
 3 files changed, 2 insertions(+), 4 deletions(-)

diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml
index 6f18da360aab9..06feb7e174322 100644
--- a/models/fixtures/action_run.yml
+++ b/models/fixtures/action_run.yml
@@ -74,7 +74,6 @@
   updated: 1683636626
   need_approval: 0
   approved_by: 0
-
 -
   id: 802
   title: "workflow run list"
diff --git a/models/fixtures/action_run_job.yml b/models/fixtures/action_run_job.yml
index eaad7779e7943..0be2321cb625d 100644
--- a/models/fixtures/action_run_job.yml
+++ b/models/fixtures/action_run_job.yml
@@ -69,7 +69,6 @@
   status: 5
   started: 1683636528
   stopped: 1683636626
-
 -
   id: 203
   run_id: 802
@@ -84,4 +83,4 @@
   task_id: 51
   status: 5
   started: 1683636528
-  stopped: 1683636626
\ No newline at end of file
+  stopped: 1683636626
diff --git a/services/convert/convert.go b/services/convert/convert.go
index aa0ccb7dd19b6..75b3f1baa017c 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -35,9 +35,9 @@ import (
 	"code.gitea.io/gitea/modules/util"
 	asymkey_service "code.gitea.io/gitea/services/asymkey"
 	"code.gitea.io/gitea/services/gitdiff"
-	"github.com/nektos/act/pkg/model"
 
 	runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
+	"github.com/nektos/act/pkg/model"
 )
 
 // ToEmail convert models.EmailAddress to api.Email

From 3f479a26869d558b4045ea20f808717d13a529f3 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 10:55:23 +0200
Subject: [PATCH 40/56] avoid breaking existing test by using different test
 repo

---
 models/fixtures/action_run.yml                   | 4 ++--
 models/fixtures/action_run_job.yml               | 4 ++--
 tests/integration/workflow_run_api_check_test.go | 6 +++---
 3 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml
index 06feb7e174322..e30f19a43fee4 100644
--- a/models/fixtures/action_run.yml
+++ b/models/fixtures/action_run.yml
@@ -77,8 +77,8 @@
 -
   id: 802
   title: "workflow run list"
-  repo_id: 4
-  owner_id: 1
+  repo_id: 5
+  owner_id: 3
   workflow_id: "test.yaml"
   index: 191
   trigger_user_id: 1
diff --git a/models/fixtures/action_run_job.yml b/models/fixtures/action_run_job.yml
index 0be2321cb625d..73138b1c6bea5 100644
--- a/models/fixtures/action_run_job.yml
+++ b/models/fixtures/action_run_job.yml
@@ -72,8 +72,8 @@
 -
   id: 203
   run_id: 802
-  repo_id: 4
-  owner_id: 1
+  repo_id: 5
+  owner_id: 3
   commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
   is_fork_pull_request: 0
   name: job2
diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go
index f43ce717c7746..25e9ed647549c 100644
--- a/tests/integration/workflow_run_api_check_test.go
+++ b/tests/integration/workflow_run_api_check_test.go
@@ -17,15 +17,15 @@ import (
 
 func TestAPIWorkflowRunRepoApi(t *testing.T) {
 	defer tests.PrepareTestEnv(t)()
-	userUsername := "user5"
+	userUsername := "user2"
 	token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteRepository)
 
-	req := NewRequest(t, "GET", "/api/v1/repos/user5/repo4/actions/runs").AddTokenAuth(token)
+	req := NewRequest(t, "GET", "/api/v1/repos/org3/repo5/actions/runs").AddTokenAuth(token)
 	runnerListResp := MakeRequest(t, req, http.StatusOK)
 	runnerList := api.ActionWorkflowRunsResponse{}
 	DecodeJSON(t, runnerListResp, &runnerList)
 
-	assert.Len(t, runnerList.Entries, 5)
+	assert.Len(t, runnerList.Entries, 1)
 
 	foundRun := false
 

From f82178edb3a76b70ce1b2e9197c9c15d29c485ae Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 13:26:14 +0200
Subject: [PATCH 41/56] add more test and more level of runs / jobs access

---
 models/actions/run_job_list.go                |  15 +-
 models/actions/run_list.go                    |  13 +-
 models/fixtures/action_run.yml                |  19 ++
 models/fixtures/action_run_job.yml            |  17 +-
 routers/api/v1/admin/runners.go               |  36 +++
 routers/api/v1/api.go                         |  22 +-
 routers/api/v1/org/action.go                  |  30 +++
 routers/api/v1/repo/action.go                 | 217 +++++++-----------
 routers/api/v1/shared/runners.go              | 142 ++++++++++++
 routers/api/v1/user/runners.go                |  26 +++
 services/actions/interface.go                 |   4 +
 templates/swagger/v1_json.tmpl                | 167 +++++++++++++-
 .../workflow_run_api_check_test.go            |  26 ++-
 13 files changed, 577 insertions(+), 157 deletions(-)

diff --git a/models/actions/run_job_list.go b/models/actions/run_job_list.go
index 1d50c9c8dd054..17f4339a53658 100644
--- a/models/actions/run_job_list.go
+++ b/models/actions/run_job_list.go
@@ -85,9 +85,6 @@ func (opts FindRunJobOptions) ToConds() builder.Cond {
 	if opts.RepoID > 0 {
 		cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
 	}
-	if opts.OwnerID > 0 {
-		cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
-	}
 	if opts.CommitSHA != "" {
 		cond = cond.And(builder.Eq{"commit_sha": opts.CommitSHA})
 	}
@@ -99,3 +96,15 @@ func (opts FindRunJobOptions) ToConds() builder.Cond {
 	}
 	return cond
 }
+
+func (opts FindRunJobOptions) ToJoins() []db.JoinFunc {
+	if opts.OwnerID > 0 {
+		return []db.JoinFunc{
+			func(sess db.Engine) error {
+				sess.Join("INNER", "repository", "repository.id = repo_id AND repository.owner_id = ?", opts.OwnerID)
+				return nil
+			},
+		}
+	}
+	return nil
+}
diff --git a/models/actions/run_list.go b/models/actions/run_list.go
index b9b9324e0754f..e48f45aa7d932 100644
--- a/models/actions/run_list.go
+++ b/models/actions/run_list.go
@@ -79,9 +79,6 @@ func (opts FindRunOptions) ToConds() builder.Cond {
 	if opts.RepoID > 0 {
 		cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
 	}
-	if opts.OwnerID > 0 {
-		cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
-	}
 	if opts.WorkflowID != "" {
 		cond = cond.And(builder.Eq{"workflow_id": opts.WorkflowID})
 	}
@@ -103,6 +100,16 @@ func (opts FindRunOptions) ToConds() builder.Cond {
 	return cond
 }
 
+func (opts FindRunOptions) ToJoins() []db.JoinFunc {
+	if opts.OwnerID > 0 {
+		return []db.JoinFunc{func(sess db.Engine) error {
+			sess.Join("INNER", "repository", "repository.id = repo_id AND repository.owner_id = ?", opts.OwnerID)
+			return nil
+		}}
+	}
+	return nil
+}
+
 func (opts FindRunOptions) ToOrders() string {
 	return "`id` DESC"
 }
diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml
index e30f19a43fee4..f285750482fcf 100644
--- a/models/fixtures/action_run.yml
+++ b/models/fixtures/action_run.yml
@@ -93,3 +93,22 @@
   updated: 1683636626
   need_approval: 0
   approved_by: 0
+-
+  id: 803
+  title: "workflow run list for user"
+  repo_id: 2
+  owner_id: 0
+  workflow_id: "test.yaml"
+  index: 191
+  trigger_user_id: 1
+  ref: "refs/heads/test"
+  commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
+  event: "push"
+  is_fork_pull_request: 0
+  status: 1
+  started: 1683636528
+  stopped: 1683636626
+  created: 1683636108
+  updated: 1683636626
+  need_approval: 0
+  approved_by: 0
diff --git a/models/fixtures/action_run_job.yml b/models/fixtures/action_run_job.yml
index 73138b1c6bea5..8b16f7b6f8c43 100644
--- a/models/fixtures/action_run_job.yml
+++ b/models/fixtures/action_run_job.yml
@@ -73,7 +73,22 @@
   id: 203
   run_id: 802
   repo_id: 5
-  owner_id: 3
+  owner_id: 0
+  commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
+  is_fork_pull_request: 0
+  name: job2
+  attempt: 1
+  job_id: job2
+  needs: '["job1"]'
+  task_id: 51
+  status: 5
+  started: 1683636528
+  stopped: 1683636626
+-
+  id: 204
+  run_id: 803
+  repo_id: 2
+  owner_id: 0
   commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
   is_fork_pull_request: 0
   name: job2
diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go
index 736c421229910..8fad9304ab332 100644
--- a/routers/api/v1/admin/runners.go
+++ b/routers/api/v1/admin/runners.go
@@ -102,3 +102,39 @@ func DeleteRunner(ctx *context.APIContext) {
 	//     "$ref": "#/responses/notFound"
 	shared.DeleteRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
 }
+
+// ListWorkflowJobs Lists all jobs
+func ListWorkflowJobs(ctx *context.APIContext) {
+	// swagger:operation GET /admin/actions/jobs admin listAdminWorkflowJobs
+	// ---
+	// summary: Lists all jobs
+	// produces:
+	// - application/json
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/JobList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	shared.ListJobs(ctx, 0, 0, 0)
+}
+
+// ListWorkflowRuns Lists all runs
+func ListWorkflowRuns(ctx *context.APIContext) {
+	// swagger:operation GET /admin/actions/runs admin listAdminWorkflowRuns
+	// ---
+	// summary: Lists all runs
+	// produces:
+	// - application/json
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/RunList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	shared.ListRuns(ctx, 0, 0)
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 70111b6af8d10..713c52bc726d7 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -942,6 +942,8 @@ func Routes() *web.Router {
 				m.Get("/{runner_id}", reqToken(), reqChecker, act.GetRunner)
 				m.Delete("/{runner_id}", reqToken(), reqChecker, act.DeleteRunner)
 			})
+			m.Get("/runs", reqToken(), reqChecker, act.ListWorkflowRuns)
+			m.Get("/jobs", reqToken(), reqChecker, act.ListWorkflowJobs)
 		})
 	}
 
@@ -1077,6 +1079,9 @@ func Routes() *web.Router {
 					m.Get("/{runner_id}", reqToken(), user.GetRunner)
 					m.Delete("/{runner_id}", reqToken(), user.DeleteRunner)
 				})
+
+				m.Get("/runs", reqToken(), user.ListWorkflowRuns)
+				m.Get("/jobs", reqToken(), user.ListWorkflowJobs)
 			})
 
 			m.Get("/followers", user.ListMyFollowers)
@@ -1281,10 +1286,9 @@ func Routes() *web.Router {
 				m.Group("/actions", func() {
 					m.Get("/tasks", repo.ListActionTasks)
 					m.Group("/runs", func() {
-						m.Get("", repo.GetWorkflowRuns)
 						m.Group("/{run}", func() {
 							m.Get("", repo.GetWorkflowRun)
-							m.Get("/jobs", repo.GetWorkflowJobs)
+							m.Get("/jobs", repo.ListWorkflowRunJobs)
 							m.Get("/artifacts", repo.GetArtifactsOfRun)
 						})
 					})
@@ -1737,11 +1741,15 @@ func Routes() *web.Router {
 					Patch(bind(api.EditHookOption{}), admin.EditHook).
 					Delete(admin.DeleteHook)
 			})
-			m.Group("/actions/runners", func() {
-				m.Get("", admin.ListRunners)
-				m.Post("/registration-token", admin.CreateRegistrationToken)
-				m.Get("/{runner_id}", admin.GetRunner)
-				m.Delete("/{runner_id}", admin.DeleteRunner)
+			m.Group("/actions", func() {
+				m.Group("/runners", func() {
+					m.Get("", admin.ListRunners)
+					m.Post("/registration-token", admin.CreateRegistrationToken)
+					m.Get("/{runner_id}", admin.GetRunner)
+					m.Delete("/{runner_id}", admin.DeleteRunner)
+				})
+				m.Get("/runs", admin.ListWorkflowRuns)
+				m.Get("/jobs", admin.ListWorkflowJobs)
 			})
 			m.Group("/runners", func() {
 				m.Get("/registration-token", admin.GetRegistrationToken)
diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go
index 700a5ef8ea852..40640753ed1bd 100644
--- a/routers/api/v1/org/action.go
+++ b/routers/api/v1/org/action.go
@@ -570,6 +570,36 @@ func (Action) DeleteRunner(ctx *context.APIContext) {
 	shared.DeleteRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
 }
 
+func (Action) ListWorkflowJobs(ctx *context.APIContext) {
+	// swagger:operation GET /orgs/{org}/actions/jobs organization getOrgWorkflowJobs
+	// ---
+	// summary: Get org-level workflow jobs
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the organization
+	//   type: string
+	//   required: true
+	shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0)
+}
+
+func (Action) ListWorkflowRuns(ctx *context.APIContext) {
+	// swagger:operation GET /orgs/{org}/actions/runs organization getOrgWorkflowRuns
+	// ---
+	// summary: Get org-level workflow runs
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the organization
+	//   type: string
+	//   required: true
+	shared.ListRuns(ctx, ctx.Org.Organization.ID, 0)
+}
+
 var _ actions_service.API = new(Action)
 
 // Action implements actions_service.API
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index a390f31e81e77..f20ec410a9855 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -21,13 +21,11 @@ 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"
@@ -652,6 +650,83 @@ func (Action) DeleteRunner(ctx *context.APIContext) {
 	shared.DeleteRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
 }
 
+// GetWorkflowRunJobs Lists all jobs for a workflow run.
+func (Action) ListWorkflowJobs(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/actions/jobs repository listWorkflowJobs
+	// ---
+	// summary: Lists all jobs for a repository
+	// 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
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/JobList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	repoID := ctx.Repo.Repository.ID
+
+	shared.ListJobs(ctx, 0, repoID, 0)
+}
+
+// ListWorkflowRuns Lists all runs for a repository run.
+func (Action) ListWorkflowRuns(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
+
+	shared.ListRuns(ctx, 0, repoID)
+}
+
 var _ actions_service.API = new(Action)
 
 // Action implements actions_service.API
@@ -994,109 +1069,6 @@ 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
@@ -1143,9 +1115,9 @@ func GetWorkflowRun(ctx *context.APIContext) {
 	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
+// ListWorkflowRunJobs Lists all jobs for a workflow run.
+func ListWorkflowRunJobs(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/jobs repository listWorkflowRunJobs
 	// ---
 	// summary: Lists all jobs for a workflow run
 	// produces:
@@ -1168,7 +1140,7 @@ func GetWorkflowJobs(ctx *context.APIContext) {
 	//   required: true
 	// responses:
 	//   "200":
-	//     "$ref": "#/responses/ArtifactsList"
+	//     "$ref": "#/responses/JobList"
 	//   "400":
 	//     "$ref": "#/responses/error"
 	//   "404":
@@ -1178,30 +1150,7 @@ func GetWorkflowJobs(ctx *context.APIContext) {
 
 	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)
+	shared.ListJobs(ctx, 0, repoID, runID)
 }
 
 // GetWorkflowJob Gets a specific workflow job for a workflow run.
@@ -1229,7 +1178,7 @@ func GetWorkflowJob(ctx *context.APIContext) {
 	//   required: true
 	// responses:
 	//   "200":
-	//     "$ref": "#/responses/Artifact"
+	//     "$ref": "#/responses/Job"
 	//   "400":
 	//     "$ref": "#/responses/error"
 	//   "404":
diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go
index d42f330d1cc3e..68310cf078cc0 100644
--- a/routers/api/v1/shared/runners.go
+++ b/routers/api/v1/shared/runners.go
@@ -9,9 +9,13 @@ import (
 
 	actions_model "code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/models/db"
+	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/setting"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/modules/webhook"
 	"code.gitea.io/gitea/routers/api/v1/utils"
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/convert"
@@ -116,3 +120,141 @@ func DeleteRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) {
 	}
 	ctx.Status(http.StatusNoContent)
 }
+
+// ListJobs lists jobs for api route validated ownerID and repoID
+// ownerID == 0 and repoID == 0 means all jobs
+// ownerID == 0 and repoID != 0 means all jobs for the given repo
+// ownerID != 0 and repoID == 0 means all jobs for the given user/org
+// ownerID != 0 and repoID != 0 undefined behavior
+// runID == 0 means all jobs
+// Access rights are checked at the API route level
+func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
+	if ownerID != 0 && repoID != 0 {
+		setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
+	}
+	jobs, total, err := db.FindAndCount[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{
+		OwnerID:     ownerID,
+		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(jobs))
+
+	isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID
+	for i := range jobs {
+		var repository *repo_model.Repository
+		if isRepoLevel {
+			repository = ctx.Repo.Repository
+		} else {
+			repository, err = repo_model.GetRepositoryByID(ctx, repoID)
+			if err != nil {
+				ctx.APIErrorInternal(err)
+				return
+			}
+		}
+
+		convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, repository, nil, jobs[i])
+		if err != nil {
+			ctx.APIErrorInternal(err)
+			return
+		}
+		res.Entries[i] = convertedWorkflowJob
+	}
+
+	ctx.JSON(http.StatusOK, &res)
+}
+
+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
+	}
+}
+
+// ListRuns lists jobs for api route validated ownerID and repoID
+// ownerID == 0 and repoID == 0 means all runs
+// ownerID == 0 and repoID != 0 means all runs for the given repo
+// ownerID != 0 and repoID == 0 means all runs for the given user/org
+// ownerID != 0 and repoID != 0 undefined behavior
+// Access rights are checked at the API route level
+func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
+	if ownerID != 0 && repoID != 0 {
+		setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
+	}
+	opts := actions_model.FindRunOptions{
+		OwnerID:     ownerID,
+		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, err := user_model.GetUserByName(ctx, actor)
+		if err != nil {
+			ctx.APIErrorInternal(err)
+			return
+		}
+		opts.TriggerUserID = user.ID
+	}
+
+	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))
+	isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID
+	for i := range runs {
+		var repository *repo_model.Repository
+		if isRepoLevel {
+			repository = ctx.Repo.Repository
+		} else {
+			repository, err = repo_model.GetRepositoryByID(ctx, runs[i].RepoID)
+			if err != nil {
+				ctx.APIErrorInternal(err)
+				return
+			}
+		}
+
+		convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i])
+		if err != nil {
+			ctx.APIErrorInternal(err)
+			return
+		}
+		res.Entries[i] = convertedRun
+	}
+
+	ctx.JSON(http.StatusOK, &res)
+}
diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go
index be3f63cc5e17f..2409d2868507d 100644
--- a/routers/api/v1/user/runners.go
+++ b/routers/api/v1/user/runners.go
@@ -102,3 +102,29 @@ func DeleteRunner(ctx *context.APIContext) {
 	//     "$ref": "#/responses/notFound"
 	shared.DeleteRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
 }
+
+// ListWorkflowRuns lists workflow runs
+func ListWorkflowRuns(ctx *context.APIContext) {
+	// swagger:operation GET /user/actions/runs user getUserWorkflowRuns
+	// ---
+	// summary: Get workflow runs
+	// produces:
+	// - application/json
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/ActionWorkflowRunsResponse"
+	shared.ListRuns(ctx, ctx.Doer.ID, 0)
+}
+
+// ListWorkflowJobs lists workflow jobs
+func ListWorkflowJobs(ctx *context.APIContext) {
+	// swagger:operation GET /user/actions/jobs user getUserWorkflowJobs
+	// ---
+	// summary: Get workflow jobs
+	// produces:
+	// - application/json
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/ActionWorkflowJobsResponse"
+	shared.ListJobs(ctx, ctx.Doer.ID, 0, 0)
+}
diff --git a/services/actions/interface.go b/services/actions/interface.go
index b407f5c6c84bd..a054c38e4f52c 100644
--- a/services/actions/interface.go
+++ b/services/actions/interface.go
@@ -33,4 +33,8 @@ type API interface {
 	GetRunner(*context.APIContext)
 	// DeleteRunner delete runner
 	DeleteRunner(*context.APIContext)
+	// ListWorkflowJobs list jobs
+	ListWorkflowJobs(*context.APIContext)
+	// ListWorkflowRuns list runs
+	ListWorkflowRuns(*context.APIContext)
 }
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index f5789f4091326..707e226f5e6d2 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -75,6 +75,29 @@
         }
       }
     },
+    "/admin/actions/jobs": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "Lists all jobs",
+        "operationId": "listAdminWorkflowJobs",
+        "responses": {
+          "200": {
+            "$ref": "#/responses/JobList"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/admin/actions/runners": {
       "get": {
         "produces": [
@@ -177,6 +200,29 @@
         }
       }
     },
+    "/admin/actions/runs": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "Lists all runs",
+        "operationId": "listAdminWorkflowRuns",
+        "responses": {
+          "200": {
+            "$ref": "#/responses/RunList"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/admin/cron": {
       "get": {
         "produces": [
@@ -1799,6 +1845,27 @@
         }
       }
     },
+    "/orgs/{org}/actions/jobs": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Get org-level workflow jobs",
+        "operationId": "getOrgWorkflowJobs",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "name of the organization",
+            "name": "org",
+            "in": "path",
+            "required": true
+          }
+        ]
+      }
+    },
     "/orgs/{org}/actions/runners": {
       "get": {
         "produces": [
@@ -1957,6 +2024,27 @@
         }
       }
     },
+    "/orgs/{org}/actions/runs": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Get org-level workflow runs",
+        "operationId": "getOrgWorkflowRuns",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "name of the organization",
+            "name": "org",
+            "in": "path",
+            "required": true
+          }
+        ]
+      }
+    },
     "/orgs/{org}/actions/secrets": {
       "get": {
         "produces": [
@@ -4519,6 +4607,45 @@
         }
       }
     },
+    "/repos/{owner}/{repo}/actions/jobs": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Lists all jobs for a repository",
+        "operationId": "listWorkflowJobs",
+        "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
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/JobList"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/actions/jobs/{job_id}": {
       "get": {
         "produces": [
@@ -4554,7 +4681,7 @@
         ],
         "responses": {
           "200": {
-            "$ref": "#/responses/Artifact"
+            "$ref": "#/responses/Job"
           },
           "400": {
             "$ref": "#/responses/error"
@@ -4968,7 +5095,7 @@
           "repository"
         ],
         "summary": "Lists all jobs for a workflow run",
-        "operationId": "getWorkflowJobs",
+        "operationId": "listWorkflowRunJobs",
         "parameters": [
           {
             "type": "string",
@@ -4994,7 +5121,7 @@
         ],
         "responses": {
           "200": {
-            "$ref": "#/responses/ArtifactsList"
+            "$ref": "#/responses/JobList"
           },
           "400": {
             "$ref": "#/responses/error"
@@ -17662,6 +17789,23 @@
         }
       }
     },
+    "/user/actions/jobs": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Get workflow jobs",
+        "operationId": "getUserWorkflowJobs",
+        "responses": {
+          "200": {
+            "$ref": "#/responses/ActionWorkflowJobsResponse"
+          }
+        }
+      }
+    },
     "/user/actions/runners": {
       "get": {
         "produces": [
@@ -17779,6 +17923,23 @@
         }
       }
     },
+    "/user/actions/runs": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Get workflow runs",
+        "operationId": "getUserWorkflowRuns",
+        "responses": {
+          "200": {
+            "$ref": "#/responses/ActionWorkflowRunsResponse"
+          }
+        }
+      }
+    },
     "/user/actions/secrets/{secretname}": {
       "put": {
         "consumes": [
diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go
index 25e9ed647549c..618830512a87b 100644
--- a/tests/integration/workflow_run_api_check_test.go
+++ b/tests/integration/workflow_run_api_check_test.go
@@ -15,17 +15,31 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestAPIWorkflowRunRepoApi(t *testing.T) {
+func TestAPIWorkflowRun(t *testing.T) {
+	t.Run("AdminRunner", func(t *testing.T) {
+		testAPIWorkflowRunBasic(t, "/api/v1/admin/actions/runs", 6, "User1", 802, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadRepository)
+	})
+	t.Run("UserRunner", func(t *testing.T) {
+		testAPIWorkflowRunBasic(t, "/api/v1/user/actions/runs", 1, "User2", 803, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
+	})
+	t.Run("OrgRuns", func(t *testing.T) {
+		testAPIWorkflowRunBasic(t, "/api/v1/orgs/org3/actions/runs", 1, "User1", 802, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository)
+	})
+	t.Run("RepoRuns", func(t *testing.T) {
+		testAPIWorkflowRunBasic(t, "/api/v1/repos/org3/repo5/actions/runs", 1, "User2", 802, auth_model.AccessTokenScopeReadRepository)
+	})
+}
+
+func testAPIWorkflowRunBasic(t *testing.T, runAPIURL string, itemCount int, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) {
 	defer tests.PrepareTestEnv(t)()
-	userUsername := "user2"
-	token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteRepository)
+	token := getUserToken(t, userUsername, scope...)
 
-	req := NewRequest(t, "GET", "/api/v1/repos/org3/repo5/actions/runs").AddTokenAuth(token)
+	req := NewRequest(t, "GET", runAPIURL).AddTokenAuth(token)
 	runnerListResp := MakeRequest(t, req, http.StatusOK)
 	runnerList := api.ActionWorkflowRunsResponse{}
 	DecodeJSON(t, runnerListResp, &runnerList)
 
-	assert.Len(t, runnerList.Entries, 1)
+	assert.Len(t, runnerList.Entries, itemCount)
 
 	foundRun := false
 
@@ -35,7 +49,7 @@ func TestAPIWorkflowRunRepoApi(t *testing.T) {
 		jobList := api.ActionWorkflowJobsResponse{}
 		DecodeJSON(t, jobsResp, &jobList)
 
-		if run.ID == 802 {
+		if run.ID == runID {
 			foundRun = true
 			assert.Len(t, jobList.Entries, 1)
 			for _, job := range jobList.Entries {

From dfd27c1b603f4043366318215f392a792202c626 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 13:42:36 +0200
Subject: [PATCH 42/56] fix swagger consistency

---
 modules/structs/repo_actions.go |   4 +-
 routers/api/v1/admin/runners.go |   4 +-
 routers/api/v1/repo/action.go   |   8 +-
 routers/api/v1/swagger/repo.go  |  28 +++++
 routers/api/v1/user/runners.go  |   2 +-
 templates/swagger/v1_json.tmpl  | 202 ++++++++++++++++++++++++++++++--
 6 files changed, 232 insertions(+), 16 deletions(-)

diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
index 933a9f77f9ab1..b19db3cbdbfa7 100644
--- a/modules/structs/repo_actions.go
+++ b/modules/structs/repo_actions.go
@@ -107,13 +107,13 @@ type ActionWorkflowRun struct {
 	CompletedAt time.Time `json:"completed_at,omitempty"`
 }
 
-// ActionArtifactsResponse returns ActionArtifacts
+// ActionWorkflowRunsResponse returns ActionWorkflowRuns
 type ActionWorkflowRunsResponse struct {
 	Entries    []*ActionWorkflowRun `json:"workflow_runs"`
 	TotalCount int64                `json:"total_count"`
 }
 
-// ActionArtifactsResponse returns ActionArtifacts
+// ActionWorkflowJobsResponse returns ActionWorkflowJobs
 type ActionWorkflowJobsResponse struct {
 	Entries    []*ActionWorkflowJob `json:"jobs"`
 	TotalCount int64                `json:"total_count"`
diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go
index 8fad9304ab332..97e80d59a980b 100644
--- a/routers/api/v1/admin/runners.go
+++ b/routers/api/v1/admin/runners.go
@@ -112,7 +112,7 @@ func ListWorkflowJobs(ctx *context.APIContext) {
 	// - application/json
 	// responses:
 	//   "200":
-	//     "$ref": "#/responses/JobList"
+	//     "$ref": "#/responses/WorkflowJobsList"
 	//   "400":
 	//     "$ref": "#/responses/error"
 	//   "404":
@@ -130,7 +130,7 @@ func ListWorkflowRuns(ctx *context.APIContext) {
 	// - application/json
 	// responses:
 	//   "200":
-	//     "$ref": "#/responses/RunList"
+	//     "$ref": "#/responses/WorkflowRunsList"
 	//   "400":
 	//     "$ref": "#/responses/error"
 	//   "404":
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index f20ec410a9855..7370471ac056e 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -670,7 +670,7 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
 	//   required: true
 	// responses:
 	//   "200":
-	//     "$ref": "#/responses/JobList"
+	//     "$ref": "#/responses/WorkflowJobsList"
 	//   "400":
 	//     "$ref": "#/responses/error"
 	//   "404":
@@ -1094,7 +1094,7 @@ func GetWorkflowRun(ctx *context.APIContext) {
 	//   required: true
 	// responses:
 	//   "200":
-	//     "$ref": "#/responses/Artifact"
+	//     "$ref": "#/responses/WorkflowRun"
 	//   "400":
 	//     "$ref": "#/responses/error"
 	//   "404":
@@ -1140,7 +1140,7 @@ func ListWorkflowRunJobs(ctx *context.APIContext) {
 	//   required: true
 	// responses:
 	//   "200":
-	//     "$ref": "#/responses/JobList"
+	//     "$ref": "#/responses/WorkflowJobsList"
 	//   "400":
 	//     "$ref": "#/responses/error"
 	//   "404":
@@ -1178,7 +1178,7 @@ func GetWorkflowJob(ctx *context.APIContext) {
 	//   required: true
 	// responses:
 	//   "200":
-	//     "$ref": "#/responses/Job"
+	//     "$ref": "#/responses/WorkflowJob"
 	//   "400":
 	//     "$ref": "#/responses/error"
 	//   "404":
diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go
index df0c8a805aaea..df043b71d3135 100644
--- a/routers/api/v1/swagger/repo.go
+++ b/routers/api/v1/swagger/repo.go
@@ -443,6 +443,34 @@ type swaggerRepoTasksList struct {
 	Body api.ActionTaskResponse `json:"body"`
 }
 
+// WorkflowRunsList
+// swagger:response WorkflowRunsList
+type swaggerActionWorkflowRunsResponse struct {
+	// in:body
+	Body api.ActionWorkflowRunsResponse `json:"body"`
+}
+
+// WorkflowRun
+// swagger:response WorkflowRun
+type swaggerWorkflowRun struct {
+	// in:body
+	Body api.ActionWorkflowRun `json:"body"`
+}
+
+// WorkflowJobsList
+// swagger:response WorkflowJobsList
+type swaggerActionWorkflowJobsResponse struct {
+	// in:body
+	Body api.ActionWorkflowJobsResponse `json:"body"`
+}
+
+// WorkflowJob
+// swagger:response WorkflowJob
+type swaggerWorkflowJob struct {
+	// in:body
+	Body api.ActionWorkflowJob `json:"body"`
+}
+
 // ArtifactsList
 // swagger:response ArtifactsList
 type swaggerRepoArtifactsList struct {
diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go
index 2409d2868507d..70b608d006a80 100644
--- a/routers/api/v1/user/runners.go
+++ b/routers/api/v1/user/runners.go
@@ -125,6 +125,6 @@ func ListWorkflowJobs(ctx *context.APIContext) {
 	// - application/json
 	// responses:
 	//   "200":
-	//     "$ref": "#/responses/ActionWorkflowJobsResponse"
+	//     "$ref": "#/responses/WorkflowJobsList"
 	shared.ListJobs(ctx, ctx.Doer.ID, 0, 0)
 }
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 707e226f5e6d2..4fcf4090e1b27 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -87,7 +87,7 @@
         "operationId": "listAdminWorkflowJobs",
         "responses": {
           "200": {
-            "$ref": "#/responses/JobList"
+            "$ref": "#/responses/WorkflowJobsList"
           },
           "400": {
             "$ref": "#/responses/error"
@@ -212,7 +212,7 @@
         "operationId": "listAdminWorkflowRuns",
         "responses": {
           "200": {
-            "$ref": "#/responses/RunList"
+            "$ref": "#/responses/WorkflowRunsList"
           },
           "400": {
             "$ref": "#/responses/error"
@@ -4635,7 +4635,7 @@
         ],
         "responses": {
           "200": {
-            "$ref": "#/responses/JobList"
+            "$ref": "#/responses/WorkflowJobsList"
           },
           "400": {
             "$ref": "#/responses/error"
@@ -4681,7 +4681,7 @@
         ],
         "responses": {
           "200": {
-            "$ref": "#/responses/Job"
+            "$ref": "#/responses/WorkflowJob"
           },
           "400": {
             "$ref": "#/responses/error"
@@ -5023,7 +5023,7 @@
         ],
         "responses": {
           "200": {
-            "$ref": "#/responses/Artifact"
+            "$ref": "#/responses/WorkflowRun"
           },
           "400": {
             "$ref": "#/responses/error"
@@ -5121,7 +5121,7 @@
         ],
         "responses": {
           "200": {
-            "$ref": "#/responses/JobList"
+            "$ref": "#/responses/WorkflowJobsList"
           },
           "400": {
             "$ref": "#/responses/error"
@@ -17801,7 +17801,7 @@
         "operationId": "getUserWorkflowJobs",
         "responses": {
           "200": {
-            "$ref": "#/responses/ActionWorkflowJobsResponse"
+            "$ref": "#/responses/WorkflowJobsList"
           }
         }
       }
@@ -20682,6 +20682,117 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "ActionWorkflowJob": {
+      "description": "ActionWorkflowJob represents a WorkflowJob",
+      "type": "object",
+      "properties": {
+        "completed_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "CompletedAt"
+        },
+        "conclusion": {
+          "type": "string",
+          "x-go-name": "Conclusion"
+        },
+        "created_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "CreatedAt"
+        },
+        "head_branch": {
+          "type": "string",
+          "x-go-name": "HeadBranch"
+        },
+        "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"
+        },
+        "labels": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "x-go-name": "Labels"
+        },
+        "name": {
+          "type": "string",
+          "x-go-name": "Name"
+        },
+        "run_attempt": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "RunAttempt"
+        },
+        "run_id": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "RunID"
+        },
+        "run_url": {
+          "type": "string",
+          "x-go-name": "RunURL"
+        },
+        "runner_id": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "RunnerID"
+        },
+        "runner_name": {
+          "type": "string",
+          "x-go-name": "RunnerName"
+        },
+        "started_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "StartedAt"
+        },
+        "status": {
+          "type": "string",
+          "x-go-name": "Status"
+        },
+        "steps": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/ActionWorkflowStep"
+          },
+          "x-go-name": "Steps"
+        },
+        "url": {
+          "type": "string",
+          "x-go-name": "URL"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
+    "ActionWorkflowJobsResponse": {
+      "description": "ActionWorkflowJobsResponse returns ActionWorkflowJobs",
+      "type": "object",
+      "properties": {
+        "jobs": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/ActionWorkflowJob"
+          },
+          "x-go-name": "Entries"
+        },
+        "total_count": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "TotalCount"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "ActionWorkflowRun": {
       "description": "ActionWorkflowRun represents a WorkflowRun",
       "type": "object",
@@ -20761,6 +20872,59 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "ActionWorkflowRunsResponse": {
+      "description": "ActionWorkflowRunsResponse returns ActionWorkflowRuns",
+      "type": "object",
+      "properties": {
+        "total_count": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "TotalCount"
+        },
+        "workflow_runs": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/ActionWorkflowRun"
+          },
+          "x-go-name": "Entries"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
+    "ActionWorkflowStep": {
+      "description": "ActionWorkflowStep represents a step of a WorkflowJob",
+      "type": "object",
+      "properties": {
+        "completed_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "CompletedAt"
+        },
+        "conclusion": {
+          "type": "string",
+          "x-go-name": "Conclusion"
+        },
+        "name": {
+          "type": "string",
+          "x-go-name": "Name"
+        },
+        "number": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "Number"
+        },
+        "started_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "StartedAt"
+        },
+        "status": {
+          "type": "string",
+          "x-go-name": "Status"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "Activity": {
       "type": "object",
       "properties": {
@@ -28873,6 +29037,30 @@
         }
       }
     },
+    "WorkflowJob": {
+      "description": "WorkflowJob",
+      "schema": {
+        "$ref": "#/definitions/ActionWorkflowJob"
+      }
+    },
+    "WorkflowJobsList": {
+      "description": "WorkflowJobsList",
+      "schema": {
+        "$ref": "#/definitions/ActionWorkflowJobsResponse"
+      }
+    },
+    "WorkflowRun": {
+      "description": "WorkflowRun",
+      "schema": {
+        "$ref": "#/definitions/ActionWorkflowRun"
+      }
+    },
+    "WorkflowRunsList": {
+      "description": "WorkflowRunsList",
+      "schema": {
+        "$ref": "#/definitions/ActionWorkflowRunsResponse"
+      }
+    },
     "conflict": {
       "description": "APIConflict is a conflict empty response"
     },

From 1845ac7428e9d907fcc03579cc224eef54e5b81c Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 13:48:01 +0200
Subject: [PATCH 43/56] cleanup

---
 routers/api/v1/user/runners.go | 2 +-
 templates/swagger/v1_json.tmpl | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go
index 70b608d006a80..60d09489773d9 100644
--- a/routers/api/v1/user/runners.go
+++ b/routers/api/v1/user/runners.go
@@ -112,7 +112,7 @@ func ListWorkflowRuns(ctx *context.APIContext) {
 	// - application/json
 	// responses:
 	//   "200":
-	//     "$ref": "#/responses/ActionWorkflowRunsResponse"
+	//     "$ref": "#/responses/WorkflowRunsList"
 	shared.ListRuns(ctx, ctx.Doer.ID, 0)
 }
 
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 4fcf4090e1b27..a50216aa0f7a5 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -17935,7 +17935,7 @@
         "operationId": "getUserWorkflowRuns",
         "responses": {
           "200": {
-            "$ref": "#/responses/ActionWorkflowRunsResponse"
+            "$ref": "#/responses/WorkflowRunsList"
           }
         }
       }

From 0a5a6ebc9b1e37c0669eb5031044727881526561 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 14:24:00 +0200
Subject: [PATCH 44/56] sync

---
 routers/api/v1/org/action.go   | 14 ++++++++++++++
 templates/swagger/v1_json.tmpl | 26 ++++++++++++++++++++++++--
 2 files changed, 38 insertions(+), 2 deletions(-)

diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go
index 40640753ed1bd..2785312df6599 100644
--- a/routers/api/v1/org/action.go
+++ b/routers/api/v1/org/action.go
@@ -582,6 +582,13 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
 	//   description: name of the organization
 	//   type: string
 	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/WorkflowJobsList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
 	shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0)
 }
 
@@ -597,6 +604,13 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) {
 	//   description: name of the organization
 	//   type: string
 	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/WorkflowRunsList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
 	shared.ListRuns(ctx, ctx.Org.Organization.ID, 0)
 }
 
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index a50216aa0f7a5..5bf4d41faba7c 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -1863,7 +1863,18 @@
             "in": "path",
             "required": true
           }
-        ]
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/WorkflowJobsList"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
       }
     },
     "/orgs/{org}/actions/runners": {
@@ -2042,7 +2053,18 @@
             "in": "path",
             "required": true
           }
-        ]
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/WorkflowRunsList"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
       }
     },
     "/orgs/{org}/actions/secrets": {

From 27c9c279d8e0527337abd857507adc585404978f Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 14:27:44 +0200
Subject: [PATCH 45/56] fix tests

---
 models/actions/run_list.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/models/actions/run_list.go b/models/actions/run_list.go
index e48f45aa7d932..d7c0854dcdde4 100644
--- a/models/actions/run_list.go
+++ b/models/actions/run_list.go
@@ -111,7 +111,7 @@ func (opts FindRunOptions) ToJoins() []db.JoinFunc {
 }
 
 func (opts FindRunOptions) ToOrders() string {
-	return "`id` DESC"
+	return "`action_run`.`id` DESC"
 }
 
 type StatusInfo struct {

From 48379a2bb532d1da5a26239ce236334b6ea41f51 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 17:44:23 +0200
Subject: [PATCH 46/56] improve tests and fix bugs

---
 models/actions/run_job_list.go                |  10 +-
 models/actions/run_list.go                    |  14 +-
 models/fixtures/action_run.yml                |   8 +-
 routers/api/v1/admin/runners.go               |  43 ++++
 routers/api/v1/org/action.go                  |  41 +++
 routers/api/v1/repo/action.go                 |  39 +++
 routers/api/v1/shared/runners.go              |  57 +++--
 routers/api/v1/user/runners.go                |  52 ++++
 services/convert/convert.go                   |   2 +
 templates/swagger/v1_json.tmpl                | 236 ++++++++++++++++++
 .../workflow_run_api_check_test.go            |  74 +++++-
 11 files changed, 548 insertions(+), 28 deletions(-)

diff --git a/models/actions/run_job_list.go b/models/actions/run_job_list.go
index 17f4339a53658..5f7bb62878ae2 100644
--- a/models/actions/run_job_list.go
+++ b/models/actions/run_job_list.go
@@ -80,19 +80,19 @@ type FindRunJobOptions struct {
 func (opts FindRunJobOptions) ToConds() builder.Cond {
 	cond := builder.NewCond()
 	if opts.RunID > 0 {
-		cond = cond.And(builder.Eq{"run_id": opts.RunID})
+		cond = cond.And(builder.Eq{"`action_run_job`.run_id": opts.RunID})
 	}
 	if opts.RepoID > 0 {
-		cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
+		cond = cond.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID})
 	}
 	if opts.CommitSHA != "" {
-		cond = cond.And(builder.Eq{"commit_sha": opts.CommitSHA})
+		cond = cond.And(builder.Eq{"`action_run_job`.commit_sha": opts.CommitSHA})
 	}
 	if len(opts.Statuses) > 0 {
-		cond = cond.And(builder.In("status", opts.Statuses))
+		cond = cond.And(builder.In("`action_run_job`.status", opts.Statuses))
 	}
 	if opts.UpdatedBefore > 0 {
-		cond = cond.And(builder.Lt{"updated": opts.UpdatedBefore})
+		cond = cond.And(builder.Lt{"`action_run_job`.updated": opts.UpdatedBefore})
 	}
 	return cond
 }
diff --git a/models/actions/run_list.go b/models/actions/run_list.go
index d7c0854dcdde4..bbc30fd888414 100644
--- a/models/actions/run_list.go
+++ b/models/actions/run_list.go
@@ -77,25 +77,25 @@ type FindRunOptions struct {
 func (opts FindRunOptions) ToConds() builder.Cond {
 	cond := builder.NewCond()
 	if opts.RepoID > 0 {
-		cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
+		cond = cond.And(builder.Eq{"`action_run`.repo_id": opts.RepoID})
 	}
 	if opts.WorkflowID != "" {
-		cond = cond.And(builder.Eq{"workflow_id": opts.WorkflowID})
+		cond = cond.And(builder.Eq{"`action_run`.workflow_id": opts.WorkflowID})
 	}
 	if opts.TriggerUserID > 0 {
-		cond = cond.And(builder.Eq{"trigger_user_id": opts.TriggerUserID})
+		cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
 	}
 	if opts.Approved {
-		cond = cond.And(builder.Gt{"approved_by": 0})
+		cond = cond.And(builder.Gt{"`action_run`.approved_by": 0})
 	}
 	if len(opts.Status) > 0 {
-		cond = cond.And(builder.In("status", opts.Status))
+		cond = cond.And(builder.In("`action_run`.status", opts.Status))
 	}
 	if opts.Ref != "" {
-		cond = cond.And(builder.Eq{"ref": opts.Ref})
+		cond = cond.And(builder.Eq{"`action_run`.ref": opts.Ref})
 	}
 	if opts.TriggerEvent != "" {
-		cond = cond.And(builder.Eq{"trigger_event": opts.TriggerEvent})
+		cond = cond.And(builder.Eq{"`action_run`.trigger_event": opts.TriggerEvent})
 	}
 	return cond
 }
diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml
index f285750482fcf..dfe09ca7a5f32 100644
--- a/models/fixtures/action_run.yml
+++ b/models/fixtures/action_run.yml
@@ -9,6 +9,7 @@
   ref: "refs/heads/master"
   commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
   event: "push"
+  trigger_event: "push"
   is_fork_pull_request: 0
   status: 1
   started: 1683636528
@@ -28,6 +29,7 @@
   ref: "refs/heads/master"
   commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
   event: "push"
+  trigger_event: "push"
   is_fork_pull_request: 0
   status: 1
   started: 1683636528
@@ -47,6 +49,7 @@
   ref: "refs/heads/master"
   commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
   event: "push"
+  trigger_event: "push"
   is_fork_pull_request: 0
   status: 1
   started: 1683636528
@@ -66,6 +69,7 @@
   ref: "refs/heads/test"
   commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
   event: "push"
+  trigger_event: "push"
   is_fork_pull_request: 0
   status: 1
   started: 1683636528
@@ -85,6 +89,7 @@
   ref: "refs/heads/test"
   commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
   event: "push"
+  trigger_event: "push"
   is_fork_pull_request: 0
   status: 1
   started: 1683636528
@@ -99,11 +104,12 @@
   repo_id: 2
   owner_id: 0
   workflow_id: "test.yaml"
-  index: 191
+  index: 192
   trigger_user_id: 1
   ref: "refs/heads/test"
   commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
   event: "push"
+  trigger_event: "push"
   is_fork_pull_request: 0
   status: 1
   started: 1683636528
diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go
index 97e80d59a980b..865b79128348c 100644
--- a/routers/api/v1/admin/runners.go
+++ b/routers/api/v1/admin/runners.go
@@ -110,6 +110,20 @@ func ListWorkflowJobs(ctx *context.APIContext) {
 	// summary: Lists all jobs
 	// produces:
 	// - application/json
+	// parameters:
+	// - name: status
+	//   in: query
+	//   description: workflow status (pending, queued, in_progress, failure, success, skipped)
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/WorkflowJobsList"
@@ -128,6 +142,35 @@ func ListWorkflowRuns(ctx *context.APIContext) {
 	// summary: Lists all runs
 	// produces:
 	// - application/json
+	// parameters:
+	// - 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
+	// - name: actor
+	//   in: query
+	//   description: triggered by user
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/WorkflowRunsList"
diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go
index 2785312df6599..2c4b66f017705 100644
--- a/routers/api/v1/org/action.go
+++ b/routers/api/v1/org/action.go
@@ -582,6 +582,19 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
 	//   description: name of the organization
 	//   type: string
 	//   required: true
+	// - name: status
+	//   in: query
+	//   description: workflow status (pending, queued, in_progress, failure, success, skipped)
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/WorkflowJobsList"
@@ -604,6 +617,34 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) {
 	//   description: name of the organization
 	//   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
+	// - name: actor
+	//   in: query
+	//   description: triggered by user
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/WorkflowRunsList"
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index 7370471ac056e..e1468cb67ae9b 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -668,6 +668,19 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
 	//   description: name of the repository
 	//   type: string
 	//   required: true
+	// - name: status
+	//   in: query
+	//   description: workflow status (pending, queued, in_progress, failure, success, skipped)
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/WorkflowJobsList"
@@ -714,6 +727,19 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) {
 	//   description: workflow status (pending, queued, in_progress, failure, success, skipped)
 	//   type: string
 	//   required: false
+	// - name: actor
+	//   in: query
+	//   description: triggered by user
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/ArtifactsList"
@@ -1138,6 +1164,19 @@ func ListWorkflowRunJobs(ctx *context.APIContext) {
 	//   description: runid of the workflow run
 	//   type: integer
 	//   required: true
+	// - name: status
+	//   in: query
+	//   description: workflow status (pending, queued, in_progress, failure, success, skipped)
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/WorkflowJobsList"
diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go
index 68310cf078cc0..dd9b92155dabb 100644
--- a/routers/api/v1/shared/runners.go
+++ b/routers/api/v1/shared/runners.go
@@ -5,6 +5,7 @@ package shared
 
 import (
 	"errors"
+	"fmt"
 	"net/http"
 
 	actions_model "code.gitea.io/gitea/models/actions"
@@ -132,12 +133,24 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
 	if ownerID != 0 && repoID != 0 {
 		setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
 	}
-	jobs, total, err := db.FindAndCount[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{
+	opts := actions_model.FindRunJobOptions{
 		OwnerID:     ownerID,
 		RepoID:      repoID,
 		RunID:       runID,
 		ListOptions: utils.GetListOptions(ctx),
-	})
+	}
+	if statuses, ok := ctx.Req.URL.Query()["status"]; ok {
+		for _, status := range statuses {
+			values, err := convertToInternal(status)
+			if err != nil {
+				ctx.APIError(http.StatusBadRequest, fmt.Errorf("Invalid status %s", status))
+				return
+			}
+			opts.Statuses = append(opts.Statuses, values...)
+		}
+	}
+
+	jobs, total, err := db.FindAndCount[actions_model.ActionRunJob](ctx, opts)
 	if err != nil {
 		ctx.APIErrorInternal(err)
 		return
@@ -172,22 +185,31 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
 	ctx.JSON(http.StatusOK, &res)
 }
 
-func convertToInternal(s string) actions_model.Status {
+func convertToInternal(s string) ([]actions_model.Status, error) {
 	switch s {
-	case "pending":
-		return actions_model.StatusBlocked
+	case "pending", "waiting", "requested", "action_required":
+		return []actions_model.Status{actions_model.StatusBlocked}, nil
 	case "queued":
-		return actions_model.StatusWaiting
+		return []actions_model.Status{actions_model.StatusWaiting}, nil
 	case "in_progress":
-		return actions_model.StatusRunning
+		return []actions_model.Status{actions_model.StatusRunning}, nil
+	case "completed":
+		return []actions_model.Status{
+			actions_model.StatusSuccess,
+			actions_model.StatusFailure,
+			actions_model.StatusSkipped,
+			actions_model.StatusCancelled,
+		}, nil
 	case "failure":
-		return actions_model.StatusFailure
+		return []actions_model.Status{actions_model.StatusFailure}, nil
 	case "success":
-		return actions_model.StatusSuccess
-	case "skipped":
-		return actions_model.StatusSkipped
+		return []actions_model.Status{actions_model.StatusSuccess}, nil
+	case "skipped", "neutral":
+		return []actions_model.Status{actions_model.StatusSkipped}, nil
+	case "cancelled", "timed_out":
+		return []actions_model.Status{actions_model.StatusCancelled}, nil
 	default:
-		return actions_model.StatusUnknown
+		return nil, fmt.Errorf("invalid status %s", s)
 	}
 }
 
@@ -213,8 +235,15 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
 	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 statuses, ok := ctx.Req.URL.Query()["status"]; ok {
+		for _, status := range statuses {
+			values, err := convertToInternal(status)
+			if err != nil {
+				ctx.APIError(http.StatusBadRequest, fmt.Errorf("Invalid status %s", status))
+				return
+			}
+			opts.Status = append(opts.Status, values...)
+		}
 	}
 	if actor := ctx.Req.URL.Query().Get("actor"); actor != "" {
 		user, err := user_model.GetUserByName(ctx, actor)
diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go
index 60d09489773d9..3fc89735855a9 100644
--- a/routers/api/v1/user/runners.go
+++ b/routers/api/v1/user/runners.go
@@ -108,11 +108,44 @@ func ListWorkflowRuns(ctx *context.APIContext) {
 	// swagger:operation GET /user/actions/runs user getUserWorkflowRuns
 	// ---
 	// summary: Get workflow runs
+	// parameters:
+	// - 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
+	// - name: actor
+	//   in: query
+	//   description: triggered by user
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
 	// produces:
 	// - application/json
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/WorkflowRunsList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
 	shared.ListRuns(ctx, ctx.Doer.ID, 0)
 }
 
@@ -121,10 +154,29 @@ func ListWorkflowJobs(ctx *context.APIContext) {
 	// swagger:operation GET /user/actions/jobs user getUserWorkflowJobs
 	// ---
 	// summary: Get workflow jobs
+	// parameters:
+	// - name: status
+	//   in: query
+	//   description: workflow status (pending, queued, in_progress, failure, success, skipped)
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
 	// produces:
 	// - application/json
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/WorkflowJobsList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
 	shared.ListJobs(ctx, ctx.Doer.ID, 0, 0)
 }
diff --git a/services/convert/convert.go b/services/convert/convert.go
index 75b3f1baa017c..a30ab7b658b83 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -306,6 +306,8 @@ func ToActionsStatus(status actions_model.Status) (string, string) {
 			conclusion = "cancelled"
 		case actions_model.StatusFailure:
 			conclusion = "failure"
+		case actions_model.StatusSkipped:
+			conclusion = "skipped"
 		}
 	}
 	return action, conclusion
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 5bf4d41faba7c..2e163c546835e 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -85,6 +85,26 @@
         ],
         "summary": "Lists all jobs",
         "operationId": "listAdminWorkflowJobs",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
+            "name": "status",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
+          }
+        ],
         "responses": {
           "200": {
             "$ref": "#/responses/WorkflowJobsList"
@@ -210,6 +230,44 @@
         ],
         "summary": "Lists all runs",
         "operationId": "listAdminWorkflowRuns",
+        "parameters": [
+          {
+            "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"
+          },
+          {
+            "type": "string",
+            "description": "triggered by user",
+            "name": "actor",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
+          }
+        ],
         "responses": {
           "200": {
             "$ref": "#/responses/WorkflowRunsList"
@@ -1862,6 +1920,24 @@
             "name": "org",
             "in": "path",
             "required": true
+          },
+          {
+            "type": "string",
+            "description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
+            "name": "status",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
           }
         ],
         "responses": {
@@ -2052,6 +2128,42 @@
             "name": "org",
             "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"
+          },
+          {
+            "type": "string",
+            "description": "triggered by user",
+            "name": "actor",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
           }
         ],
         "responses": {
@@ -4653,6 +4765,24 @@
             "name": "repo",
             "in": "path",
             "required": true
+          },
+          {
+            "type": "string",
+            "description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
+            "name": "status",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
           }
         ],
         "responses": {
@@ -4995,6 +5125,24 @@
             "description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
             "name": "status",
             "in": "query"
+          },
+          {
+            "type": "string",
+            "description": "triggered by user",
+            "name": "actor",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
           }
         ],
         "responses": {
@@ -5139,6 +5287,24 @@
             "name": "run",
             "in": "path",
             "required": true
+          },
+          {
+            "type": "string",
+            "description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
+            "name": "status",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
           }
         ],
         "responses": {
@@ -17821,9 +17987,35 @@
         ],
         "summary": "Get workflow jobs",
         "operationId": "getUserWorkflowJobs",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
+            "name": "status",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
+          }
+        ],
         "responses": {
           "200": {
             "$ref": "#/responses/WorkflowJobsList"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
           }
         }
       }
@@ -17955,9 +18147,53 @@
         ],
         "summary": "Get workflow runs",
         "operationId": "getUserWorkflowRuns",
+        "parameters": [
+          {
+            "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"
+          },
+          {
+            "type": "string",
+            "description": "triggered by user",
+            "name": "actor",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page number of results to return (1-based)",
+            "name": "page",
+            "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
+          }
+        ],
         "responses": {
           "200": {
             "$ref": "#/responses/WorkflowRunsList"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
           }
         }
       }
diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go
index 618830512a87b..cd6d49d6223c9 100644
--- a/tests/integration/workflow_run_api_check_test.go
+++ b/tests/integration/workflow_run_api_check_test.go
@@ -6,6 +6,7 @@ package integration
 import (
 	"fmt"
 	"net/http"
+	"net/url"
 	"testing"
 
 	auth_model "code.gitea.io/gitea/models/auth"
@@ -44,6 +45,11 @@ func testAPIWorkflowRunBasic(t *testing.T, runAPIURL string, itemCount int, user
 	foundRun := false
 
 	for _, run := range runnerList.Entries {
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, runAPIURL, token, run.ID, "", run.Status, "", "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, runAPIURL, token, run.ID, run.Conclusion, "", "", "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, runAPIURL, token, run.ID, "", "", "", run.HeadBranch)
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, runAPIURL, token, run.ID, "", "", run.Event, "")
+
 		req := NewRequest(t, "GET", fmt.Sprintf("%s/%s", run.URL, "jobs")).AddTokenAuth(token)
 		jobsResp := MakeRequest(t, req, http.StatusOK)
 		jobList := api.ActionWorkflowJobsResponse{}
@@ -53,6 +59,9 @@ func testAPIWorkflowRunBasic(t *testing.T, runAPIURL string, itemCount int, user
 			foundRun = true
 			assert.Len(t, jobList.Entries, 1)
 			for _, job := range jobList.Entries {
+				verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", run.URL, "jobs"), token, job.ID, "", job.Status)
+				verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", run.URL, "jobs"), token, job.ID, job.Conclusion, "")
+
 				req := NewRequest(t, "GET", job.URL).AddTokenAuth(token)
 				jobsResp := MakeRequest(t, req, http.StatusOK)
 				apiJob := api.ActionWorkflowJob{}
@@ -64,5 +73,68 @@ func testAPIWorkflowRunBasic(t *testing.T, runAPIURL string, itemCount int, user
 			}
 		}
 	}
-	assert.True(t, foundRun, "Expected to find run with ID 802")
+	assert.True(t, foundRun, "Expected to find run with ID %d", runID)
+}
+
+func verifyWorkflowRunCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token string, id int64, conclusion, status, event, branch string) {
+	filter := url.Values{}
+	if conclusion != "" {
+		filter.Add("status", conclusion)
+	}
+	if status != "" {
+		filter.Add("status", status)
+	}
+	if event != "" {
+		filter.Set("event", event)
+	}
+	if branch != "" {
+		filter.Set("branch", branch)
+	}
+	req := NewRequest(t, "GET", runAPIURL+"?"+filter.Encode()).AddTokenAuth(token)
+	runResp := MakeRequest(t, req, http.StatusOK)
+	runList := api.ActionWorkflowRunsResponse{}
+	DecodeJSON(t, runResp, &runList)
+
+	found := false
+	for _, run := range runList.Entries {
+		if conclusion != "" {
+			assert.Equal(t, conclusion, run.Conclusion)
+		}
+		if status != "" {
+			assert.Equal(t, status, run.Status)
+		}
+		if event != "" {
+			assert.Equal(t, event, run.Event)
+		}
+		if branch != "" {
+			assert.Equal(t, branch, run.HeadBranch)
+		}
+		found = found || run.ID == id
+	}
+	assert.True(t, found, "Expected to find run with ID %d", id)
+}
+
+func verifyWorkflowJobCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token string, id int64, conclusion, status string) {
+	filter := conclusion
+	if filter == "" {
+		filter = status
+	}
+	if filter == "" {
+		return
+	}
+	req := NewRequest(t, "GET", runAPIURL+"?status="+filter).AddTokenAuth(token)
+	jobListResp := MakeRequest(t, req, http.StatusOK)
+	jobList := api.ActionWorkflowJobsResponse{}
+	DecodeJSON(t, jobListResp, &jobList)
+
+	found := false
+	for _, job := range jobList.Entries {
+		if conclusion != "" {
+			assert.Equal(t, conclusion, job.Conclusion)
+		} else {
+			assert.Equal(t, status, job.Status)
+		}
+		found = found || job.ID == id
+	}
+	assert.True(t, found, "Expected to find job with ID %d", id)
 }

From 5be456b05b8d621d89f74af525341935499a9c59 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 18:29:11 +0200
Subject: [PATCH 47/56] add actor and triggerActor

---
 modules/structs/repo_actions.go               |  2 ++
 routers/api/v1/shared/runners.go              |  2 +-
 services/convert/convert.go                   |  5 ++-
 .../workflow_run_api_check_test.go            | 34 +++++++++++++------
 4 files changed, 30 insertions(+), 13 deletions(-)

diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
index b19db3cbdbfa7..9a486cd1d6a7e 100644
--- a/modules/structs/repo_actions.go
+++ b/modules/structs/repo_actions.go
@@ -98,6 +98,8 @@ type ActionWorkflowRun struct {
 	HeadSha        string      `json:"head_sha"`
 	HeadBranch     string      `json:"head_branch,omitempty"`
 	Status         string      `json:"status"`
+	Actor          *User       `json:"actor,omitempty"`
+	TriggerActor   *User       `json:"trigger_actor,omitempty"`
 	Repository     *Repository `json:"repository,omitempty"`
 	HeadRepository *Repository `json:"head_repository,omitempty"`
 	Conclusion     string      `json:"conclusion,omitempty"`
diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go
index dd9b92155dabb..5f392a290e595 100644
--- a/routers/api/v1/shared/runners.go
+++ b/routers/api/v1/shared/runners.go
@@ -167,7 +167,7 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
 		if isRepoLevel {
 			repository = ctx.Repo.Repository
 		} else {
-			repository, err = repo_model.GetRepositoryByID(ctx, repoID)
+			repository, err = repo_model.GetRepositoryByID(ctx, jobs[i].RepoID)
 			if err != nil {
 				ctx.APIErrorInternal(err)
 				return
diff --git a/services/convert/convert.go b/services/convert/convert.go
index a30ab7b658b83..e6a1b12cf952b 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -248,7 +248,7 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action
 }
 
 func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflowRun, error) {
-	err := run.LoadRepo(ctx)
+	err := run.LoadAttributes(ctx)
 	if err != nil {
 		return nil, err
 	}
@@ -268,6 +268,9 @@ func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *
 		Conclusion:   conclusion,
 		Path:         fmt.Sprintf("%s@%s", run.WorkflowID, run.Ref),
 		Repository:   ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
+		TriggerActor: ToUser(ctx, run.TriggerUser, nil),
+		// We do not have a way to get a different User for the actor than the trigger user
+		Actor: ToUser(ctx, run.TriggerUser, nil),
 	}, nil
 }
 
diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go
index cd6d49d6223c9..60808c25bdb74 100644
--- a/tests/integration/workflow_run_api_check_test.go
+++ b/tests/integration/workflow_run_api_check_test.go
@@ -18,24 +18,25 @@ import (
 
 func TestAPIWorkflowRun(t *testing.T) {
 	t.Run("AdminRunner", func(t *testing.T) {
-		testAPIWorkflowRunBasic(t, "/api/v1/admin/actions/runs", 6, "User1", 802, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadRepository)
+		testAPIWorkflowRunBasic(t, "/api/v1/admin/actions", 6, "User1", 802, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadRepository)
 	})
 	t.Run("UserRunner", func(t *testing.T) {
-		testAPIWorkflowRunBasic(t, "/api/v1/user/actions/runs", 1, "User2", 803, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
+		testAPIWorkflowRunBasic(t, "/api/v1/user/actions", 1, "User2", 803, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
 	})
 	t.Run("OrgRuns", func(t *testing.T) {
-		testAPIWorkflowRunBasic(t, "/api/v1/orgs/org3/actions/runs", 1, "User1", 802, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository)
+		testAPIWorkflowRunBasic(t, "/api/v1/orgs/org3/actions", 1, "User1", 802, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository)
 	})
 	t.Run("RepoRuns", func(t *testing.T) {
-		testAPIWorkflowRunBasic(t, "/api/v1/repos/org3/repo5/actions/runs", 1, "User2", 802, auth_model.AccessTokenScopeReadRepository)
+		testAPIWorkflowRunBasic(t, "/api/v1/repos/org3/repo5/actions", 1, "User2", 802, auth_model.AccessTokenScopeReadRepository)
 	})
 }
 
-func testAPIWorkflowRunBasic(t *testing.T, runAPIURL string, itemCount int, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) {
+func testAPIWorkflowRunBasic(t *testing.T, apiRootURL string, itemCount int, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) {
 	defer tests.PrepareTestEnv(t)()
 	token := getUserToken(t, userUsername, scope...)
 
-	req := NewRequest(t, "GET", runAPIURL).AddTokenAuth(token)
+	apiRunsURL := fmt.Sprintf("%s/%s", apiRootURL, "runs")
+	req := NewRequest(t, "GET", apiRunsURL).AddTokenAuth(token)
 	runnerListResp := MakeRequest(t, req, http.StatusOK)
 	runnerList := api.ActionWorkflowRunsResponse{}
 	DecodeJSON(t, runnerListResp, &runnerList)
@@ -45,10 +46,11 @@ func testAPIWorkflowRunBasic(t *testing.T, runAPIURL string, itemCount int, user
 	foundRun := false
 
 	for _, run := range runnerList.Entries {
-		verifyWorkflowRunCanbeFoundWithStatusFilter(t, runAPIURL, token, run.ID, "", run.Status, "", "")
-		verifyWorkflowRunCanbeFoundWithStatusFilter(t, runAPIURL, token, run.ID, run.Conclusion, "", "", "")
-		verifyWorkflowRunCanbeFoundWithStatusFilter(t, runAPIURL, token, run.ID, "", "", "", run.HeadBranch)
-		verifyWorkflowRunCanbeFoundWithStatusFilter(t, runAPIURL, token, run.ID, "", "", run.Event, "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", run.Status, "", "", "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, run.Conclusion, "", "", "", "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", run.HeadBranch, "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", run.Event, "", "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", "", run.TriggerActor.UserName)
 
 		req := NewRequest(t, "GET", fmt.Sprintf("%s/%s", run.URL, "jobs")).AddTokenAuth(token)
 		jobsResp := MakeRequest(t, req, http.StatusOK)
@@ -59,8 +61,12 @@ func testAPIWorkflowRunBasic(t *testing.T, runAPIURL string, itemCount int, user
 			foundRun = true
 			assert.Len(t, jobList.Entries, 1)
 			for _, job := range jobList.Entries {
+				// Check the jobs list of the run
 				verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", run.URL, "jobs"), token, job.ID, "", job.Status)
 				verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", run.URL, "jobs"), token, job.ID, job.Conclusion, "")
+				// Check the run independent job list
+				verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", apiRootURL, "jobs"), token, job.ID, "", job.Status)
+				verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", apiRootURL, "jobs"), token, job.ID, job.Conclusion, "")
 
 				req := NewRequest(t, "GET", job.URL).AddTokenAuth(token)
 				jobsResp := MakeRequest(t, req, http.StatusOK)
@@ -76,7 +82,7 @@ func testAPIWorkflowRunBasic(t *testing.T, runAPIURL string, itemCount int, user
 	assert.True(t, foundRun, "Expected to find run with ID %d", runID)
 }
 
-func verifyWorkflowRunCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token string, id int64, conclusion, status, event, branch string) {
+func verifyWorkflowRunCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token string, id int64, conclusion, status, event, branch, actor string) {
 	filter := url.Values{}
 	if conclusion != "" {
 		filter.Add("status", conclusion)
@@ -90,6 +96,9 @@ func verifyWorkflowRunCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token
 	if branch != "" {
 		filter.Set("branch", branch)
 	}
+	if actor != "" {
+		filter.Set("actor", actor)
+	}
 	req := NewRequest(t, "GET", runAPIURL+"?"+filter.Encode()).AddTokenAuth(token)
 	runResp := MakeRequest(t, req, http.StatusOK)
 	runList := api.ActionWorkflowRunsResponse{}
@@ -109,6 +118,9 @@ func verifyWorkflowRunCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token
 		if branch != "" {
 			assert.Equal(t, branch, run.HeadBranch)
 		}
+		if actor != "" {
+			assert.Equal(t, actor, run.Actor.UserName)
+		}
 		found = found || run.ID == id
 	}
 	assert.True(t, found, "Expected to find run with ID %d", id)

From 7c7bd1d586481abb6f76a0d1f1206126ff2a5bbf Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 18:35:28 +0200
Subject: [PATCH 48/56] add headsha filter

---
 models/actions/run_list.go                       |  4 ++++
 routers/api/v1/shared/runners.go                 |  3 +++
 tests/integration/workflow_run_api_check_test.go | 16 ++++++++++------
 3 files changed, 17 insertions(+), 6 deletions(-)

diff --git a/models/actions/run_list.go b/models/actions/run_list.go
index bbc30fd888414..12c55e538e7f7 100644
--- a/models/actions/run_list.go
+++ b/models/actions/run_list.go
@@ -72,6 +72,7 @@ type FindRunOptions struct {
 	TriggerEvent  webhook_module.HookEventType
 	Approved      bool // not util.OptionalBool, it works only when it's true
 	Status        []Status
+	CommitSHA     string
 }
 
 func (opts FindRunOptions) ToConds() builder.Cond {
@@ -97,6 +98,9 @@ func (opts FindRunOptions) ToConds() builder.Cond {
 	if opts.TriggerEvent != "" {
 		cond = cond.And(builder.Eq{"`action_run`.trigger_event": opts.TriggerEvent})
 	}
+	if opts.CommitSHA != "" {
+		cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA})
+	}
 	return cond
 }
 
diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go
index 5f392a290e595..d3f53c93560df 100644
--- a/routers/api/v1/shared/runners.go
+++ b/routers/api/v1/shared/runners.go
@@ -253,6 +253,9 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
 		}
 		opts.TriggerUserID = user.ID
 	}
+	if headSHA := ctx.Req.URL.Query().Get("head_sha"); headSHA != "" {
+		opts.CommitSHA = headSHA
+	}
 
 	runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
 	if err != nil {
diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go
index 60808c25bdb74..47af1e77b9573 100644
--- a/tests/integration/workflow_run_api_check_test.go
+++ b/tests/integration/workflow_run_api_check_test.go
@@ -46,11 +46,12 @@ func testAPIWorkflowRunBasic(t *testing.T, apiRootURL string, itemCount int, use
 	foundRun := false
 
 	for _, run := range runnerList.Entries {
-		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", run.Status, "", "", "")
-		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, run.Conclusion, "", "", "", "")
-		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", run.HeadBranch, "")
-		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", run.Event, "", "")
-		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", "", run.TriggerActor.UserName)
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", run.Status, "", "", "", "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, run.Conclusion, "", "", "", "", "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", run.HeadBranch, "", "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", run.Event, "", "", "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", "", run.TriggerActor.UserName, "")
+		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", "", run.TriggerActor.UserName, run.HeadSha)
 
 		req := NewRequest(t, "GET", fmt.Sprintf("%s/%s", run.URL, "jobs")).AddTokenAuth(token)
 		jobsResp := MakeRequest(t, req, http.StatusOK)
@@ -82,7 +83,7 @@ func testAPIWorkflowRunBasic(t *testing.T, apiRootURL string, itemCount int, use
 	assert.True(t, foundRun, "Expected to find run with ID %d", runID)
 }
 
-func verifyWorkflowRunCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token string, id int64, conclusion, status, event, branch, actor string) {
+func verifyWorkflowRunCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token string, id int64, conclusion, status, event, branch, actor, headSHA string) {
 	filter := url.Values{}
 	if conclusion != "" {
 		filter.Add("status", conclusion)
@@ -99,6 +100,9 @@ func verifyWorkflowRunCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token
 	if actor != "" {
 		filter.Set("actor", actor)
 	}
+	if headSHA != "" {
+		filter.Set("head_sha", headSHA)
+	}
 	req := NewRequest(t, "GET", runAPIURL+"?"+filter.Encode()).AddTokenAuth(token)
 	runResp := MakeRequest(t, req, http.StatusOK)
 	runList := api.ActionWorkflowRunsResponse{}

From b0f536019ae10a60d8eebed55d403abb381a501d Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 18:39:33 +0200
Subject: [PATCH 49/56] update swagger for head_sha

---
 routers/api/v1/admin/runners.go |  5 +++++
 routers/api/v1/org/action.go    |  5 +++++
 routers/api/v1/repo/action.go   |  5 +++++
 routers/api/v1/user/runners.go  |  5 +++++
 templates/swagger/v1_json.tmpl  | 30 ++++++++++++++++++++++++++++++
 5 files changed, 50 insertions(+)

diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go
index 865b79128348c..1abb314afc9ab 100644
--- a/routers/api/v1/admin/runners.go
+++ b/routers/api/v1/admin/runners.go
@@ -163,6 +163,11 @@ func ListWorkflowRuns(ctx *context.APIContext) {
 	//   description: triggered by user
 	//   type: string
 	//   required: false
+	// - name: head_sha
+	//   in: query
+	//   description: triggering sha of the workflow run
+	//   type: string
+	//   required: false
 	// - name: page
 	//   in: query
 	//   description: page number of results to return (1-based)
diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go
index 2c4b66f017705..5345d32cb3ee3 100644
--- a/routers/api/v1/org/action.go
+++ b/routers/api/v1/org/action.go
@@ -637,6 +637,11 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) {
 	//   description: triggered by user
 	//   type: string
 	//   required: false
+	// - name: head_sha
+	//   in: query
+	//   description: triggering sha of the workflow run
+	//   type: string
+	//   required: false
 	// - name: page
 	//   in: query
 	//   description: page number of results to return (1-based)
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index e1468cb67ae9b..dd4d467ba9efc 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -732,6 +732,11 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) {
 	//   description: triggered by user
 	//   type: string
 	//   required: false
+	// - name: head_sha
+	//   in: query
+	//   description: triggering sha of the workflow run
+	//   type: string
+	//   required: false
 	// - name: page
 	//   in: query
 	//   description: page number of results to return (1-based)
diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go
index 3fc89735855a9..37c606478eb06 100644
--- a/routers/api/v1/user/runners.go
+++ b/routers/api/v1/user/runners.go
@@ -129,6 +129,11 @@ func ListWorkflowRuns(ctx *context.APIContext) {
 	//   description: triggered by user
 	//   type: string
 	//   required: false
+	// - name: head_sha
+	//   in: query
+	//   description: triggering sha of the workflow run
+	//   type: string
+	//   required: false
 	// - name: page
 	//   in: query
 	//   description: page number of results to return (1-based)
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 2e163c546835e..d7de1f3b52cf9 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -255,6 +255,12 @@
             "name": "actor",
             "in": "query"
           },
+          {
+            "type": "string",
+            "description": "triggering sha of the workflow run",
+            "name": "head_sha",
+            "in": "query"
+          },
           {
             "type": "integer",
             "description": "page number of results to return (1-based)",
@@ -2153,6 +2159,12 @@
             "name": "actor",
             "in": "query"
           },
+          {
+            "type": "string",
+            "description": "triggering sha of the workflow run",
+            "name": "head_sha",
+            "in": "query"
+          },
           {
             "type": "integer",
             "description": "page number of results to return (1-based)",
@@ -5132,6 +5144,12 @@
             "name": "actor",
             "in": "query"
           },
+          {
+            "type": "string",
+            "description": "triggering sha of the workflow run",
+            "name": "head_sha",
+            "in": "query"
+          },
           {
             "type": "integer",
             "description": "page number of results to return (1-based)",
@@ -18172,6 +18190,12 @@
             "name": "actor",
             "in": "query"
           },
+          {
+            "type": "string",
+            "description": "triggering sha of the workflow run",
+            "name": "head_sha",
+            "in": "query"
+          },
           {
             "type": "integer",
             "description": "page number of results to return (1-based)",
@@ -21055,6 +21079,9 @@
       "description": "ActionWorkflowRun represents a WorkflowRun",
       "type": "object",
       "properties": {
+        "actor": {
+          "$ref": "#/definitions/User"
+        },
         "completed_at": {
           "type": "string",
           "format": "date-time",
@@ -21123,6 +21150,9 @@
           "type": "string",
           "x-go-name": "Status"
         },
+        "trigger_actor": {
+          "$ref": "#/definitions/User"
+        },
         "url": {
           "type": "string",
           "x-go-name": "URL"

From d3981cc0ece04ad4b6ec911d358fb712ce02cd43 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Sat, 3 May 2025 18:45:15 +0200
Subject: [PATCH 50/56] move implementations to correct places

---
 routers/api/v1/admin/action.go   |  93 +++++++++++++++
 routers/api/v1/admin/runners.go  |  84 --------------
 routers/api/v1/shared/action.go  | 190 +++++++++++++++++++++++++++++++
 routers/api/v1/shared/runners.go | 174 ----------------------------
 routers/api/v1/user/action.go    |  84 ++++++++++++++
 routers/api/v1/user/runners.go   |  83 --------------
 6 files changed, 367 insertions(+), 341 deletions(-)
 create mode 100644 routers/api/v1/admin/action.go
 create mode 100644 routers/api/v1/shared/action.go

diff --git a/routers/api/v1/admin/action.go b/routers/api/v1/admin/action.go
new file mode 100644
index 0000000000000..2fbb8e1a95548
--- /dev/null
+++ b/routers/api/v1/admin/action.go
@@ -0,0 +1,93 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package admin
+
+import (
+	"code.gitea.io/gitea/routers/api/v1/shared"
+	"code.gitea.io/gitea/services/context"
+)
+
+// ListWorkflowJobs Lists all jobs
+func ListWorkflowJobs(ctx *context.APIContext) {
+	// swagger:operation GET /admin/actions/jobs admin listAdminWorkflowJobs
+	// ---
+	// summary: Lists all jobs
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: status
+	//   in: query
+	//   description: workflow status (pending, queued, in_progress, failure, success, skipped)
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/WorkflowJobsList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	shared.ListJobs(ctx, 0, 0, 0)
+}
+
+// ListWorkflowRuns Lists all runs
+func ListWorkflowRuns(ctx *context.APIContext) {
+	// swagger:operation GET /admin/actions/runs admin listAdminWorkflowRuns
+	// ---
+	// summary: Lists all runs
+	// produces:
+	// - application/json
+	// parameters:
+	// - 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
+	// - name: actor
+	//   in: query
+	//   description: triggered by user
+	//   type: string
+	//   required: false
+	// - name: head_sha
+	//   in: query
+	//   description: triggering sha of the workflow run
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/WorkflowRunsList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	shared.ListRuns(ctx, 0, 0)
+}
diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go
index 1abb314afc9ab..736c421229910 100644
--- a/routers/api/v1/admin/runners.go
+++ b/routers/api/v1/admin/runners.go
@@ -102,87 +102,3 @@ func DeleteRunner(ctx *context.APIContext) {
 	//     "$ref": "#/responses/notFound"
 	shared.DeleteRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
 }
-
-// ListWorkflowJobs Lists all jobs
-func ListWorkflowJobs(ctx *context.APIContext) {
-	// swagger:operation GET /admin/actions/jobs admin listAdminWorkflowJobs
-	// ---
-	// summary: Lists all jobs
-	// produces:
-	// - application/json
-	// parameters:
-	// - name: status
-	//   in: query
-	//   description: workflow status (pending, queued, in_progress, failure, success, skipped)
-	//   type: string
-	//   required: false
-	// - name: page
-	//   in: query
-	//   description: page number of results to return (1-based)
-	//   type: integer
-	// - name: limit
-	//   in: query
-	//   description: page size of results
-	//   type: integer
-	// responses:
-	//   "200":
-	//     "$ref": "#/responses/WorkflowJobsList"
-	//   "400":
-	//     "$ref": "#/responses/error"
-	//   "404":
-	//     "$ref": "#/responses/notFound"
-
-	shared.ListJobs(ctx, 0, 0, 0)
-}
-
-// ListWorkflowRuns Lists all runs
-func ListWorkflowRuns(ctx *context.APIContext) {
-	// swagger:operation GET /admin/actions/runs admin listAdminWorkflowRuns
-	// ---
-	// summary: Lists all runs
-	// produces:
-	// - application/json
-	// parameters:
-	// - 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
-	// - name: actor
-	//   in: query
-	//   description: triggered by user
-	//   type: string
-	//   required: false
-	// - name: head_sha
-	//   in: query
-	//   description: triggering sha of the workflow run
-	//   type: string
-	//   required: false
-	// - name: page
-	//   in: query
-	//   description: page number of results to return (1-based)
-	//   type: integer
-	// - name: limit
-	//   in: query
-	//   description: page size of results
-	//   type: integer
-	// responses:
-	//   "200":
-	//     "$ref": "#/responses/WorkflowRunsList"
-	//   "400":
-	//     "$ref": "#/responses/error"
-	//   "404":
-	//     "$ref": "#/responses/notFound"
-
-	shared.ListRuns(ctx, 0, 0)
-}
diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go
new file mode 100644
index 0000000000000..507c669ece36f
--- /dev/null
+++ b/routers/api/v1/shared/action.go
@@ -0,0 +1,190 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package shared
+
+import (
+	"fmt"
+	"net/http"
+
+	actions_model "code.gitea.io/gitea/models/actions"
+	"code.gitea.io/gitea/models/db"
+	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/setting"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/webhook"
+	"code.gitea.io/gitea/routers/api/v1/utils"
+	"code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/convert"
+)
+
+// ListJobs lists jobs for api route validated ownerID and repoID
+// ownerID == 0 and repoID == 0 means all jobs
+// ownerID == 0 and repoID != 0 means all jobs for the given repo
+// ownerID != 0 and repoID == 0 means all jobs for the given user/org
+// ownerID != 0 and repoID != 0 undefined behavior
+// runID == 0 means all jobs
+// Access rights are checked at the API route level
+func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
+	if ownerID != 0 && repoID != 0 {
+		setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
+	}
+	opts := actions_model.FindRunJobOptions{
+		OwnerID:     ownerID,
+		RepoID:      repoID,
+		RunID:       runID,
+		ListOptions: utils.GetListOptions(ctx),
+	}
+	if statuses, ok := ctx.Req.URL.Query()["status"]; ok {
+		for _, status := range statuses {
+			values, err := convertToInternal(status)
+			if err != nil {
+				ctx.APIError(http.StatusBadRequest, fmt.Errorf("Invalid status %s", status))
+				return
+			}
+			opts.Statuses = append(opts.Statuses, values...)
+		}
+	}
+
+	jobs, total, err := db.FindAndCount[actions_model.ActionRunJob](ctx, opts)
+	if err != nil {
+		ctx.APIErrorInternal(err)
+		return
+	}
+
+	res := new(api.ActionWorkflowJobsResponse)
+	res.TotalCount = total
+
+	res.Entries = make([]*api.ActionWorkflowJob, len(jobs))
+
+	isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID
+	for i := range jobs {
+		var repository *repo_model.Repository
+		if isRepoLevel {
+			repository = ctx.Repo.Repository
+		} else {
+			repository, err = repo_model.GetRepositoryByID(ctx, jobs[i].RepoID)
+			if err != nil {
+				ctx.APIErrorInternal(err)
+				return
+			}
+		}
+
+		convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, repository, nil, jobs[i])
+		if err != nil {
+			ctx.APIErrorInternal(err)
+			return
+		}
+		res.Entries[i] = convertedWorkflowJob
+	}
+
+	ctx.JSON(http.StatusOK, &res)
+}
+
+func convertToInternal(s string) ([]actions_model.Status, error) {
+	switch s {
+	case "pending", "waiting", "requested", "action_required":
+		return []actions_model.Status{actions_model.StatusBlocked}, nil
+	case "queued":
+		return []actions_model.Status{actions_model.StatusWaiting}, nil
+	case "in_progress":
+		return []actions_model.Status{actions_model.StatusRunning}, nil
+	case "completed":
+		return []actions_model.Status{
+			actions_model.StatusSuccess,
+			actions_model.StatusFailure,
+			actions_model.StatusSkipped,
+			actions_model.StatusCancelled,
+		}, nil
+	case "failure":
+		return []actions_model.Status{actions_model.StatusFailure}, nil
+	case "success":
+		return []actions_model.Status{actions_model.StatusSuccess}, nil
+	case "skipped", "neutral":
+		return []actions_model.Status{actions_model.StatusSkipped}, nil
+	case "cancelled", "timed_out":
+		return []actions_model.Status{actions_model.StatusCancelled}, nil
+	default:
+		return nil, fmt.Errorf("invalid status %s", s)
+	}
+}
+
+// ListRuns lists jobs for api route validated ownerID and repoID
+// ownerID == 0 and repoID == 0 means all runs
+// ownerID == 0 and repoID != 0 means all runs for the given repo
+// ownerID != 0 and repoID == 0 means all runs for the given user/org
+// ownerID != 0 and repoID != 0 undefined behavior
+// Access rights are checked at the API route level
+func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
+	if ownerID != 0 && repoID != 0 {
+		setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
+	}
+	opts := actions_model.FindRunOptions{
+		OwnerID:     ownerID,
+		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 statuses, ok := ctx.Req.URL.Query()["status"]; ok {
+		for _, status := range statuses {
+			values, err := convertToInternal(status)
+			if err != nil {
+				ctx.APIError(http.StatusBadRequest, fmt.Errorf("Invalid status %s", status))
+				return
+			}
+			opts.Status = append(opts.Status, values...)
+		}
+	}
+	if actor := ctx.Req.URL.Query().Get("actor"); actor != "" {
+		user, err := user_model.GetUserByName(ctx, actor)
+		if err != nil {
+			ctx.APIErrorInternal(err)
+			return
+		}
+		opts.TriggerUserID = user.ID
+	}
+	if headSHA := ctx.Req.URL.Query().Get("head_sha"); headSHA != "" {
+		opts.CommitSHA = headSHA
+	}
+
+	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))
+	isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID
+	for i := range runs {
+		var repository *repo_model.Repository
+		if isRepoLevel {
+			repository = ctx.Repo.Repository
+		} else {
+			repository, err = repo_model.GetRepositoryByID(ctx, runs[i].RepoID)
+			if err != nil {
+				ctx.APIErrorInternal(err)
+				return
+			}
+		}
+
+		convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i])
+		if err != nil {
+			ctx.APIErrorInternal(err)
+			return
+		}
+		res.Entries[i] = convertedRun
+	}
+
+	ctx.JSON(http.StatusOK, &res)
+}
diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go
index d3f53c93560df..d42f330d1cc3e 100644
--- a/routers/api/v1/shared/runners.go
+++ b/routers/api/v1/shared/runners.go
@@ -5,18 +5,13 @@ package shared
 
 import (
 	"errors"
-	"fmt"
 	"net/http"
 
 	actions_model "code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/models/db"
-	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/setting"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/util"
-	"code.gitea.io/gitea/modules/webhook"
 	"code.gitea.io/gitea/routers/api/v1/utils"
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/convert"
@@ -121,172 +116,3 @@ func DeleteRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) {
 	}
 	ctx.Status(http.StatusNoContent)
 }
-
-// ListJobs lists jobs for api route validated ownerID and repoID
-// ownerID == 0 and repoID == 0 means all jobs
-// ownerID == 0 and repoID != 0 means all jobs for the given repo
-// ownerID != 0 and repoID == 0 means all jobs for the given user/org
-// ownerID != 0 and repoID != 0 undefined behavior
-// runID == 0 means all jobs
-// Access rights are checked at the API route level
-func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
-	if ownerID != 0 && repoID != 0 {
-		setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
-	}
-	opts := actions_model.FindRunJobOptions{
-		OwnerID:     ownerID,
-		RepoID:      repoID,
-		RunID:       runID,
-		ListOptions: utils.GetListOptions(ctx),
-	}
-	if statuses, ok := ctx.Req.URL.Query()["status"]; ok {
-		for _, status := range statuses {
-			values, err := convertToInternal(status)
-			if err != nil {
-				ctx.APIError(http.StatusBadRequest, fmt.Errorf("Invalid status %s", status))
-				return
-			}
-			opts.Statuses = append(opts.Statuses, values...)
-		}
-	}
-
-	jobs, total, err := db.FindAndCount[actions_model.ActionRunJob](ctx, opts)
-	if err != nil {
-		ctx.APIErrorInternal(err)
-		return
-	}
-
-	res := new(api.ActionWorkflowJobsResponse)
-	res.TotalCount = total
-
-	res.Entries = make([]*api.ActionWorkflowJob, len(jobs))
-
-	isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID
-	for i := range jobs {
-		var repository *repo_model.Repository
-		if isRepoLevel {
-			repository = ctx.Repo.Repository
-		} else {
-			repository, err = repo_model.GetRepositoryByID(ctx, jobs[i].RepoID)
-			if err != nil {
-				ctx.APIErrorInternal(err)
-				return
-			}
-		}
-
-		convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, repository, nil, jobs[i])
-		if err != nil {
-			ctx.APIErrorInternal(err)
-			return
-		}
-		res.Entries[i] = convertedWorkflowJob
-	}
-
-	ctx.JSON(http.StatusOK, &res)
-}
-
-func convertToInternal(s string) ([]actions_model.Status, error) {
-	switch s {
-	case "pending", "waiting", "requested", "action_required":
-		return []actions_model.Status{actions_model.StatusBlocked}, nil
-	case "queued":
-		return []actions_model.Status{actions_model.StatusWaiting}, nil
-	case "in_progress":
-		return []actions_model.Status{actions_model.StatusRunning}, nil
-	case "completed":
-		return []actions_model.Status{
-			actions_model.StatusSuccess,
-			actions_model.StatusFailure,
-			actions_model.StatusSkipped,
-			actions_model.StatusCancelled,
-		}, nil
-	case "failure":
-		return []actions_model.Status{actions_model.StatusFailure}, nil
-	case "success":
-		return []actions_model.Status{actions_model.StatusSuccess}, nil
-	case "skipped", "neutral":
-		return []actions_model.Status{actions_model.StatusSkipped}, nil
-	case "cancelled", "timed_out":
-		return []actions_model.Status{actions_model.StatusCancelled}, nil
-	default:
-		return nil, fmt.Errorf("invalid status %s", s)
-	}
-}
-
-// ListRuns lists jobs for api route validated ownerID and repoID
-// ownerID == 0 and repoID == 0 means all runs
-// ownerID == 0 and repoID != 0 means all runs for the given repo
-// ownerID != 0 and repoID == 0 means all runs for the given user/org
-// ownerID != 0 and repoID != 0 undefined behavior
-// Access rights are checked at the API route level
-func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
-	if ownerID != 0 && repoID != 0 {
-		setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
-	}
-	opts := actions_model.FindRunOptions{
-		OwnerID:     ownerID,
-		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 statuses, ok := ctx.Req.URL.Query()["status"]; ok {
-		for _, status := range statuses {
-			values, err := convertToInternal(status)
-			if err != nil {
-				ctx.APIError(http.StatusBadRequest, fmt.Errorf("Invalid status %s", status))
-				return
-			}
-			opts.Status = append(opts.Status, values...)
-		}
-	}
-	if actor := ctx.Req.URL.Query().Get("actor"); actor != "" {
-		user, err := user_model.GetUserByName(ctx, actor)
-		if err != nil {
-			ctx.APIErrorInternal(err)
-			return
-		}
-		opts.TriggerUserID = user.ID
-	}
-	if headSHA := ctx.Req.URL.Query().Get("head_sha"); headSHA != "" {
-		opts.CommitSHA = headSHA
-	}
-
-	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))
-	isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID
-	for i := range runs {
-		var repository *repo_model.Repository
-		if isRepoLevel {
-			repository = ctx.Repo.Repository
-		} else {
-			repository, err = repo_model.GetRepositoryByID(ctx, runs[i].RepoID)
-			if err != nil {
-				ctx.APIErrorInternal(err)
-				return
-			}
-		}
-
-		convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i])
-		if err != nil {
-			ctx.APIErrorInternal(err)
-			return
-		}
-		res.Entries[i] = convertedRun
-	}
-
-	ctx.JSON(http.StatusOK, &res)
-}
diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go
index 04097fcc95b3f..aa5327e95eeba 100644
--- a/routers/api/v1/user/action.go
+++ b/routers/api/v1/user/action.go
@@ -12,6 +12,7 @@ import (
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
+	"code.gitea.io/gitea/routers/api/v1/shared"
 	"code.gitea.io/gitea/routers/api/v1/utils"
 	actions_service "code.gitea.io/gitea/services/actions"
 	"code.gitea.io/gitea/services/context"
@@ -358,3 +359,86 @@ func ListVariables(ctx *context.APIContext) {
 	ctx.SetTotalCountHeader(count)
 	ctx.JSON(http.StatusOK, variables)
 }
+
+// ListWorkflowRuns lists workflow runs
+func ListWorkflowRuns(ctx *context.APIContext) {
+	// swagger:operation GET /user/actions/runs user getUserWorkflowRuns
+	// ---
+	// summary: Get workflow runs
+	// parameters:
+	// - 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
+	// - name: actor
+	//   in: query
+	//   description: triggered by user
+	//   type: string
+	//   required: false
+	// - name: head_sha
+	//   in: query
+	//   description: triggering sha of the workflow run
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
+	// produces:
+	// - application/json
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/WorkflowRunsList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.ListRuns(ctx, ctx.Doer.ID, 0)
+}
+
+// ListWorkflowJobs lists workflow jobs
+func ListWorkflowJobs(ctx *context.APIContext) {
+	// swagger:operation GET /user/actions/jobs user getUserWorkflowJobs
+	// ---
+	// summary: Get workflow jobs
+	// parameters:
+	// - name: status
+	//   in: query
+	//   description: workflow status (pending, queued, in_progress, failure, success, skipped)
+	//   type: string
+	//   required: false
+	// - name: page
+	//   in: query
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
+	//   type: integer
+	// produces:
+	// - application/json
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/WorkflowJobsList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	shared.ListJobs(ctx, ctx.Doer.ID, 0, 0)
+}
diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go
index 37c606478eb06..be3f63cc5e17f 100644
--- a/routers/api/v1/user/runners.go
+++ b/routers/api/v1/user/runners.go
@@ -102,86 +102,3 @@ func DeleteRunner(ctx *context.APIContext) {
 	//     "$ref": "#/responses/notFound"
 	shared.DeleteRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
 }
-
-// ListWorkflowRuns lists workflow runs
-func ListWorkflowRuns(ctx *context.APIContext) {
-	// swagger:operation GET /user/actions/runs user getUserWorkflowRuns
-	// ---
-	// summary: Get workflow runs
-	// parameters:
-	// - 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
-	// - name: actor
-	//   in: query
-	//   description: triggered by user
-	//   type: string
-	//   required: false
-	// - name: head_sha
-	//   in: query
-	//   description: triggering sha of the workflow run
-	//   type: string
-	//   required: false
-	// - name: page
-	//   in: query
-	//   description: page number of results to return (1-based)
-	//   type: integer
-	// - name: limit
-	//   in: query
-	//   description: page size of results
-	//   type: integer
-	// produces:
-	// - application/json
-	// responses:
-	//   "200":
-	//     "$ref": "#/responses/WorkflowRunsList"
-	//   "400":
-	//     "$ref": "#/responses/error"
-	//   "404":
-	//     "$ref": "#/responses/notFound"
-	shared.ListRuns(ctx, ctx.Doer.ID, 0)
-}
-
-// ListWorkflowJobs lists workflow jobs
-func ListWorkflowJobs(ctx *context.APIContext) {
-	// swagger:operation GET /user/actions/jobs user getUserWorkflowJobs
-	// ---
-	// summary: Get workflow jobs
-	// parameters:
-	// - name: status
-	//   in: query
-	//   description: workflow status (pending, queued, in_progress, failure, success, skipped)
-	//   type: string
-	//   required: false
-	// - name: page
-	//   in: query
-	//   description: page number of results to return (1-based)
-	//   type: integer
-	// - name: limit
-	//   in: query
-	//   description: page size of results
-	//   type: integer
-	// produces:
-	// - application/json
-	// responses:
-	//   "200":
-	//     "$ref": "#/responses/WorkflowJobsList"
-	//   "400":
-	//     "$ref": "#/responses/error"
-	//   "404":
-	//     "$ref": "#/responses/notFound"
-
-	shared.ListJobs(ctx, ctx.Doer.ID, 0, 0)
-}

From 0feed42c54d3ed5e6a0881a77894effe502d2504 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Mon, 5 May 2025 18:16:36 +0200
Subject: [PATCH 51/56] fix error message

---
 models/actions/run.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/models/actions/run.go b/models/actions/run.go
index a846960632104..ca817d4c1be02 100644
--- a/models/actions/run.go
+++ b/models/actions/run.go
@@ -173,7 +173,7 @@ func (run *ActionRun) GetWorkflowRunEventPayload() (*api.WorkflowRunPayload, err
 		}
 		return &payload, nil
 	}
-	return nil, fmt.Errorf("event %s is not a pull request event", run.Event)
+	return nil, fmt.Errorf("event %s is not a workflow run event", run.Event)
 }
 
 func (run *ActionRun) IsSchedule() bool {

From fe23a1d0da23d956388fdd3401f9087e1c991123 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Wed, 14 May 2025 20:17:51 +0200
Subject: [PATCH 52/56] ...

---
 services/actions/notifier_helper.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go
index d58229728a9e8..393bc05486dfa 100644
--- a/services/actions/notifier_helper.go
+++ b/services/actions/notifier_helper.go
@@ -269,7 +269,7 @@ func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit)
 			if wrun.WorkflowRun.Event != "workflow_run" {
 				return false
 			}
-			r, _ := actions_model.GetRunByID(ctx, wrun.WorkflowRun.ID)
+			r, _ := actions_model.GetRunByRepoAndID(ctx, wrun.WorkflowRun.ID, wrun.WorkflowRun.Repository.ID)
 			var err error
 			wrun, err = r.GetWorkflowRunEventPayload()
 			if err != nil {

From e7a880685dec9b8591ee5af1868d6e922edfb1ea Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Wed, 14 May 2025 20:19:13 +0200
Subject: [PATCH 53/56] use id from input

---
 services/actions/notifier_helper.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go
index 393bc05486dfa..62a745ddf4e1a 100644
--- a/services/actions/notifier_helper.go
+++ b/services/actions/notifier_helper.go
@@ -269,7 +269,7 @@ func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit)
 			if wrun.WorkflowRun.Event != "workflow_run" {
 				return false
 			}
-			r, _ := actions_model.GetRunByRepoAndID(ctx, wrun.WorkflowRun.ID, wrun.WorkflowRun.Repository.ID)
+			r, _ := actions_model.GetRunByRepoAndID(ctx, wrun.WorkflowRun.ID, input.Repo.ID)
 			var err error
 			wrun, err = r.GetWorkflowRunEventPayload()
 			if err != nil {

From 42ca070484b396d0b03fbe729da0037edf10f146 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Thu, 15 May 2025 20:32:30 +0200
Subject: [PATCH 54/56] fix regression, wrong parameter order

---
 services/actions/notifier_helper.go | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go
index 62a745ddf4e1a..3f063d0a9c340 100644
--- a/services/actions/notifier_helper.go
+++ b/services/actions/notifier_helper.go
@@ -269,8 +269,11 @@ func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit)
 			if wrun.WorkflowRun.Event != "workflow_run" {
 				return false
 			}
-			r, _ := actions_model.GetRunByRepoAndID(ctx, wrun.WorkflowRun.ID, input.Repo.ID)
-			var err error
+			r, err := actions_model.GetRunByRepoAndID(ctx, input.Repo.ID, wrun.WorkflowRun.ID)
+			if err != nil {
+				log.Error("GetRunByRepoAndID: %v", err)
+				return true
+			}
 			wrun, err = r.GetWorkflowRunEventPayload()
 			if err != nil {
 				log.Error("GetWorkflowRunEventPayload: %v", err)

From b300a49dbf2f12b9b690effffffa3a953c68ab46 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Thu, 15 May 2025 21:17:34 +0200
Subject: [PATCH 55/56] fix test regression after run deletion

---
 models/fixtures/action_run.yml                |  1 +
 .../workflow_run_api_check_test.go            | 31 +++++++++++++------
 2 files changed, 22 insertions(+), 10 deletions(-)

diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml
index ef9c8d36cca3b..09dfa6cccbba3 100644
--- a/models/fixtures/action_run.yml
+++ b/models/fixtures/action_run.yml
@@ -130,6 +130,7 @@
   ref: "refs/heads/test"
   commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
   event: "push"
+  trigger_event: "push"
   is_fork_pull_request: 0
   status: 2
   started: 1683636528
diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go
index 47af1e77b9573..5c641c42143fa 100644
--- a/tests/integration/workflow_run_api_check_test.go
+++ b/tests/integration/workflow_run_api_check_test.go
@@ -17,21 +17,21 @@ import (
 )
 
 func TestAPIWorkflowRun(t *testing.T) {
-	t.Run("AdminRunner", func(t *testing.T) {
-		testAPIWorkflowRunBasic(t, "/api/v1/admin/actions", 6, "User1", 802, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadRepository)
+	t.Run("AdminRuns", func(t *testing.T) {
+		testAPIWorkflowRunBasic(t, "/api/v1/admin/actions", "User1", 802, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadRepository)
 	})
-	t.Run("UserRunner", func(t *testing.T) {
-		testAPIWorkflowRunBasic(t, "/api/v1/user/actions", 1, "User2", 803, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
+	t.Run("UserRuns", func(t *testing.T) {
+		testAPIWorkflowRunBasic(t, "/api/v1/user/actions", "User2", 803, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
 	})
 	t.Run("OrgRuns", func(t *testing.T) {
-		testAPIWorkflowRunBasic(t, "/api/v1/orgs/org3/actions", 1, "User1", 802, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository)
+		testAPIWorkflowRunBasic(t, "/api/v1/orgs/org3/actions", "User1", 802, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository)
 	})
 	t.Run("RepoRuns", func(t *testing.T) {
-		testAPIWorkflowRunBasic(t, "/api/v1/repos/org3/repo5/actions", 1, "User2", 802, auth_model.AccessTokenScopeReadRepository)
+		testAPIWorkflowRunBasic(t, "/api/v1/repos/org3/repo5/actions", "User2", 802, auth_model.AccessTokenScopeReadRepository)
 	})
 }
 
-func testAPIWorkflowRunBasic(t *testing.T, apiRootURL string, itemCount int, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) {
+func testAPIWorkflowRunBasic(t *testing.T, apiRootURL string, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) {
 	defer tests.PrepareTestEnv(t)()
 	token := getUserToken(t, userUsername, scope...)
 
@@ -41,11 +41,10 @@ func testAPIWorkflowRunBasic(t *testing.T, apiRootURL string, itemCount int, use
 	runnerList := api.ActionWorkflowRunsResponse{}
 	DecodeJSON(t, runnerListResp, &runnerList)
 
-	assert.Len(t, runnerList.Entries, itemCount)
-
 	foundRun := false
 
 	for _, run := range runnerList.Entries {
+		// Verify filtering works
 		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", run.Status, "", "", "", "")
 		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, run.Conclusion, "", "", "", "", "")
 		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", run.HeadBranch, "", "")
@@ -53,7 +52,18 @@ func testAPIWorkflowRunBasic(t *testing.T, apiRootURL string, itemCount int, use
 		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", "", run.TriggerActor.UserName, "")
 		verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", "", run.TriggerActor.UserName, run.HeadSha)
 
-		req := NewRequest(t, "GET", fmt.Sprintf("%s/%s", run.URL, "jobs")).AddTokenAuth(token)
+		// Verify run url works
+		req := NewRequest(t, "GET", run.URL).AddTokenAuth(token)
+		runResp := MakeRequest(t, req, http.StatusOK)
+		apiRun := api.ActionWorkflowRun{}
+		DecodeJSON(t, runResp, &apiRun)
+		assert.Equal(t, run.ID, apiRun.ID)
+		assert.Equal(t, run.Status, apiRun.Status)
+		assert.Equal(t, run.Conclusion, apiRun.Conclusion)
+		assert.Equal(t, run.Event, apiRun.Event)
+
+		// Verify jobs list works
+		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)
@@ -69,6 +79,7 @@ func testAPIWorkflowRunBasic(t *testing.T, apiRootURL string, itemCount int, use
 				verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", apiRootURL, "jobs"), token, job.ID, "", job.Status)
 				verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", apiRootURL, "jobs"), token, job.ID, job.Conclusion, "")
 
+				// Verify job url works
 				req := NewRequest(t, "GET", job.URL).AddTokenAuth(token)
 				jobsResp := MakeRequest(t, req, http.StatusOK)
 				apiJob := api.ActionWorkflowJob{}

From ddc6584e42ead0f99d88a687e56e00d1204700c0 Mon Sep 17 00:00:00 2001
From: Christopher Homberger <christopher.homberger@web.de>
Date: Thu, 15 May 2025 21:22:54 +0200
Subject: [PATCH 56/56] remove redundant type

---
 tests/integration/workflow_run_api_check_test.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go
index 5c641c42143fa..6a80bb5118623 100644
--- a/tests/integration/workflow_run_api_check_test.go
+++ b/tests/integration/workflow_run_api_check_test.go
@@ -31,7 +31,7 @@ func TestAPIWorkflowRun(t *testing.T) {
 	})
 }
 
-func testAPIWorkflowRunBasic(t *testing.T, apiRootURL string, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) {
+func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) {
 	defer tests.PrepareTestEnv(t)()
 	token := getUserToken(t, userUsername, scope...)