mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 19:06:18 +01:00 
			
		
		
		
	Add workflow_run api + webhook (#33964)
Implements - https://docs.github.com/en/rest/actions/workflow-jobs?apiVersion=2022-11-28#list-jobs-for-a-workflow-run--code-samples - https://docs.github.com/en/rest/actions/workflow-jobs?apiVersion=2022-11-28#get-a-job-for-a-workflow-run--code-samples - https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-repository - https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#get-a-workflow-run - `/actions/runs` for global + user + org (Gitea only) - `/actions/jobs` for global + user + org + repository (Gitea only) - workflow_run webhook + action trigger - limitations - workflow id is assigned to a string, this may result into problems in strongly typed clients Fixes - workflow_job webhook url to no longer contain the `runs/<run>` part to align with api - workflow instance does now use it's name inside the file instead of filename if set Refactoring - Moved a lot of logic from workflows/workflow_job into a shared module used by both webhook and api TODO - [x] Verify Keda Compatibility - [x] Edit Webhook API bug is resolved Closes https://github.com/go-gitea/gitea/issues/23670 Closes https://github.com/go-gitea/gitea/issues/23796 Closes https://github.com/go-gitea/gitea/issues/24898 Replaces https://github.com/go-gitea/gitea/pull/28047 and is much more complete --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -166,6 +166,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 workflow run event", run.Event) | ||||
| } | ||||
|  | ||||
| func (run *ActionRun) IsSchedule() bool { | ||||
| 	return run.ScheduleID > 0 | ||||
| } | ||||
|   | ||||
| @@ -80,22 +80,31 @@ 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}) | ||||
| 	} | ||||
| 	if opts.OwnerID > 0 { | ||||
| 		cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) | ||||
| 		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 | ||||
| } | ||||
|  | ||||
| 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 | ||||
| } | ||||
|   | ||||
| @@ -72,39 +72,50 @@ 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 { | ||||
| 	cond := builder.NewCond() | ||||
| 	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}) | ||||
| 		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}) | ||||
| 	} | ||||
| 	if opts.CommitSHA != "" { | ||||
| 		cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA}) | ||||
| 	} | ||||
| 	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" | ||||
| 	return "`action_run`.`id` DESC" | ||||
| } | ||||
|  | ||||
| type StatusInfo struct { | ||||
|   | ||||
| @@ -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: 6 # running | ||||
|   started: 1683636528 | ||||
| @@ -66,6 +69,47 @@ | ||||
|   ref: "refs/heads/test" | ||||
|   commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0" | ||||
|   event: "push" | ||||
|   trigger_event: "push" | ||||
|   is_fork_pull_request: 0 | ||||
|   status: 1 | ||||
|   started: 1683636528 | ||||
|   stopped: 1683636626 | ||||
|   created: 1683636108 | ||||
|   updated: 1683636626 | ||||
|   need_approval: 0 | ||||
|   approved_by: 0 | ||||
| - | ||||
|   id: 802 | ||||
|   title: "workflow run list" | ||||
|   repo_id: 5 | ||||
|   owner_id: 3 | ||||
|   workflow_id: "test.yaml" | ||||
|   index: 191 | ||||
|   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 | ||||
|   stopped: 1683636626 | ||||
|   created: 1683636108 | ||||
|   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: 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 | ||||
| @@ -86,6 +130,7 @@ | ||||
|   ref: "refs/heads/test" | ||||
|   commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0" | ||||
|   event: "push" | ||||
|   trigger_event: "push" | ||||
|   is_fork_pull_request: 0 | ||||
|   status: 2 | ||||
|   started: 1683636528 | ||||
|   | ||||
| @@ -99,3 +99,33 @@ | ||||
|   status: 2 | ||||
|   started: 1683636528 | ||||
|   stopped: 1683636626 | ||||
| - | ||||
|   id: 203 | ||||
|   run_id: 802 | ||||
|   repo_id: 5 | ||||
|   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 | ||||
|   attempt: 1 | ||||
|   job_id: job2 | ||||
|   needs: '["job1"]' | ||||
|   task_id: 51 | ||||
|   status: 5 | ||||
|   started: 1683636528 | ||||
|   stopped: 1683636626 | ||||
|   | ||||
| @@ -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}, | ||||
|   | ||||
| @@ -246,6 +246,10 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web | ||||
| 		webhook_module.HookEventPackage: | ||||
| 		return matchPackageEvent(payload.(*api.PackagePayload), evt) | ||||
|  | ||||
| 	case // workflow_run | ||||
| 		webhook_module.HookEventWorkflowRun: | ||||
| 		return matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt) | ||||
|  | ||||
| 	default: | ||||
| 		log.Warn("unsupported event %q", triggedEvent) | ||||
| 		return false | ||||
| @@ -691,3 +695,53 @@ func matchPackageEvent(payload *api.PackagePayload, evt *jobparser.Event) bool { | ||||
| 	} | ||||
| 	return matchTimes == len(evt.Acts()) | ||||
| } | ||||
|  | ||||
| func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event) bool { | ||||
| 	// with no special filter parameters | ||||
| 	if len(evt.Acts()) == 0 { | ||||
| 		return true | ||||
| 	} | ||||
|  | ||||
| 	matchTimes := 0 | ||||
| 	// all acts conditions should be satisfied | ||||
| 	for cond, vals := range evt.Acts() { | ||||
| 		switch cond { | ||||
| 		case "types": | ||||
| 			action := payload.Action | ||||
| 			for _, val := range vals { | ||||
| 				if glob.MustCompile(val, '/').Match(action) { | ||||
| 					matchTimes++ | ||||
| 					break | ||||
| 				} | ||||
| 			} | ||||
| 		case "workflows": | ||||
| 			workflow := payload.Workflow | ||||
| 			patterns, err := workflowpattern.CompilePatterns(vals...) | ||||
| 			if err != nil { | ||||
| 				break | ||||
| 			} | ||||
| 			if !workflowpattern.Skip(patterns, []string{workflow.Name}, &workflowpattern.EmptyTraceWriter{}) { | ||||
| 				matchTimes++ | ||||
| 			} | ||||
| 		case "branches": | ||||
| 			patterns, err := workflowpattern.CompilePatterns(vals...) | ||||
| 			if err != nil { | ||||
| 				break | ||||
| 			} | ||||
| 			if !workflowpattern.Skip(patterns, []string{payload.WorkflowRun.HeadBranch}, &workflowpattern.EmptyTraceWriter{}) { | ||||
| 				matchTimes++ | ||||
| 			} | ||||
| 		case "branches-ignore": | ||||
| 			patterns, err := workflowpattern.CompilePatterns(vals...) | ||||
| 			if err != nil { | ||||
| 				break | ||||
| 			} | ||||
| 			if !workflowpattern.Filter(patterns, []string{payload.WorkflowRun.HeadBranch}, &workflowpattern.EmptyTraceWriter{}) { | ||||
| 				matchTimes++ | ||||
| 			} | ||||
| 		default: | ||||
| 			log.Warn("workflow run event unsupported condition %q", cond) | ||||
| 		} | ||||
| 	} | ||||
| 	return matchTimes == len(evt.Acts()) | ||||
| } | ||||
|   | ||||
| @@ -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"` | ||||
|   | ||||
| @@ -86,9 +86,39 @@ type ActionArtifact struct { | ||||
|  | ||||
| // ActionWorkflowRun represents a WorkflowRun | ||||
| type ActionWorkflowRun struct { | ||||
| 	ID           int64  `json:"id"` | ||||
| 	RepositoryID int64  `json:"repository_id"` | ||||
| 	HeadSha      string `json:"head_sha"` | ||||
| 	ID             int64       `json:"id"` | ||||
| 	URL            string      `json:"url"` | ||||
| 	HTMLURL        string      `json:"html_url"` | ||||
| 	DisplayTitle   string      `json:"display_title"` | ||||
| 	Path           string      `json:"path"` | ||||
| 	Event          string      `json:"event"` | ||||
| 	RunAttempt     int64       `json:"run_attempt"` | ||||
| 	RunNumber      int64       `json:"run_number"` | ||||
| 	RepositoryID   int64       `json:"repository_id,omitempty"` | ||||
| 	HeadSha        string      `json:"head_sha"` | ||||
| 	HeadBranch     string      `json:"head_branch,omitempty"` | ||||
| 	Status         string      `json:"status"` | ||||
| 	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"` | ||||
| 	// swagger:strfmt date-time | ||||
| 	StartedAt time.Time `json:"started_at"` | ||||
| 	// swagger:strfmt date-time | ||||
| 	CompletedAt time.Time `json:"completed_at"` | ||||
| } | ||||
|  | ||||
| // ActionWorkflowRunsResponse returns ActionWorkflowRuns | ||||
| type ActionWorkflowRunsResponse struct { | ||||
| 	Entries    []*ActionWorkflowRun `json:"workflow_runs"` | ||||
| 	TotalCount int64                `json:"total_count"` | ||||
| } | ||||
|  | ||||
| // ActionWorkflowJobsResponse returns ActionWorkflowJobs | ||||
| type ActionWorkflowJobsResponse struct { | ||||
| 	Entries    []*ActionWorkflowJob `json:"jobs"` | ||||
| 	TotalCount int64                `json:"total_count"` | ||||
| } | ||||
|  | ||||
| // ActionArtifactsResponse returns ActionArtifacts | ||||
|   | ||||
| @@ -38,6 +38,7 @@ const ( | ||||
| 	HookEventPullRequestReview HookEventType = "pull_request_review" | ||||
| 	// Actions event only | ||||
| 	HookEventSchedule    HookEventType = "schedule" | ||||
| 	HookEventWorkflowRun HookEventType = "workflow_run" | ||||
| 	HookEventWorkflowJob HookEventType = "workflow_job" | ||||
| ) | ||||
|  | ||||
| @@ -67,6 +68,7 @@ func AllEvents() []HookEventType { | ||||
| 		HookEventRelease, | ||||
| 		HookEventPackage, | ||||
| 		HookEventStatus, | ||||
| 		HookEventWorkflowRun, | ||||
| 		HookEventWorkflowJob, | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -2403,6 +2403,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 | ||||
|   | ||||
							
								
								
									
										93
									
								
								routers/api/v1/admin/action.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								routers/api/v1/admin/action.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
| @@ -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) | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| @@ -1078,6 +1080,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) | ||||
| @@ -1202,6 +1207,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)) | ||||
|  | ||||
| @@ -1280,9 +1286,13 @@ func Routes() *web.Router { | ||||
| 				}, reqToken(), reqAdmin()) | ||||
| 				m.Group("/actions", func() { | ||||
| 					m.Get("/tasks", repo.ListActionTasks) | ||||
| 					m.Group("/runs/{run}", func() { | ||||
| 						m.Get("/artifacts", repo.GetArtifactsOfRun) | ||||
| 						m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun) | ||||
| 					m.Group("/runs", func() { | ||||
| 						m.Group("/{run}", func() { | ||||
| 							m.Get("", repo.GetWorkflowRun) | ||||
| 							m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun) | ||||
| 							m.Get("/jobs", repo.ListWorkflowRunJobs) | ||||
| 							m.Get("/artifacts", repo.GetArtifactsOfRun) | ||||
| 						}) | ||||
| 					}) | ||||
| 					m.Get("/artifacts", repo.GetArtifacts) | ||||
| 					m.Group("/artifacts/{artifact_id}", func() { | ||||
| @@ -1734,11 +1744,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) | ||||
|   | ||||
| @@ -570,6 +570,96 @@ 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 | ||||
| 	// - 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, 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 | ||||
| 	// - 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, ctx.Org.Organization.ID, 0) | ||||
| } | ||||
|  | ||||
| var _ actions_service.API = new(Action) | ||||
|  | ||||
| // Action implements actions_service.API | ||||
|   | ||||
| @@ -650,6 +650,114 @@ 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 | ||||
| 	// - 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" | ||||
|  | ||||
| 	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 | ||||
| 	// - 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/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 | ||||
| @@ -756,7 +864,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 | ||||
| @@ -802,7 +910,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) | ||||
| @@ -992,6 +1100,157 @@ func ActionsEnableWorkflow(ctx *context.APIContext) { | ||||
| 	ctx.Status(http.StatusNoContent) | ||||
| } | ||||
|  | ||||
| // 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/WorkflowRun" | ||||
| 	//   "400": | ||||
| 	//     "$ref": "#/responses/error" | ||||
| 	//   "404": | ||||
| 	//     "$ref": "#/responses/notFound" | ||||
|  | ||||
| 	runID := ctx.PathParamInt64("run") | ||||
| 	job, _, err := db.GetByID[actions_model.ActionRun](ctx, runID) | ||||
|  | ||||
| 	if err != nil || job.RepoID != ctx.Repo.Repository.ID { | ||||
| 		ctx.APIError(http.StatusNotFound, util.ErrNotExist) | ||||
| 	} | ||||
|  | ||||
| 	convertedArtifact, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, job) | ||||
| 	if err != nil { | ||||
| 		ctx.APIErrorInternal(err) | ||||
| 		return | ||||
| 	} | ||||
| 	ctx.JSON(http.StatusOK, convertedArtifact) | ||||
| } | ||||
|  | ||||
| // 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: | ||||
| 	// - 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: 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" | ||||
|  | ||||
| 	repoID := ctx.Repo.Repository.ID | ||||
|  | ||||
| 	runID := ctx.PathParamInt64("run") | ||||
|  | ||||
| 	// Avoid the list all jobs functionality for this api route to be used with a runID == 0. | ||||
| 	if runID <= 0 { | ||||
| 		ctx.APIError(http.StatusBadRequest, util.NewInvalidArgumentErrorf("runID must be a positive integer")) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// runID is used as an additional filter next to repoID to ensure that we only list jobs for the specified repoID and runID. | ||||
| 	// no additional checks for runID are needed here | ||||
| 	shared.ListJobs(ctx, 0, repoID, runID) | ||||
| } | ||||
|  | ||||
| // 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/WorkflowJob" | ||||
| 	//   "400": | ||||
| 	//     "$ref": "#/responses/error" | ||||
| 	//   "404": | ||||
| 	//     "$ref": "#/responses/notFound" | ||||
|  | ||||
| 	jobID := ctx.PathParamInt64("job_id") | ||||
| 	job, _, err := db.GetByID[actions_model.ActionRunJob](ctx, jobID) | ||||
|  | ||||
| 	if err != nil || job.RepoID != ctx.Repo.Repository.ID { | ||||
| 		ctx.APIError(http.StatusNotFound, util.ErrNotExist) | ||||
| 	} | ||||
|  | ||||
| 	convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, job) | ||||
| 	if err != nil { | ||||
| 		ctx.APIErrorInternal(err) | ||||
| 		return | ||||
| 	} | ||||
| 	ctx.JSON(http.StatusOK, convertedWorkflowJob) | ||||
| } | ||||
|  | ||||
| // GetArtifacts Lists all artifacts for a repository. | ||||
| func GetArtifactsOfRun(ctx *context.APIContext) { | ||||
| 	// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/artifacts repository getArtifactsOfRun | ||||
|   | ||||
							
								
								
									
										187
									
								
								routers/api/v1/shared/action.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								routers/api/v1/shared/action.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,187 @@ | ||||
| // 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 | ||||
| // runID is used as an additional filter together with ownerID and repoID to only return jobs for the given run | ||||
| // 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), | ||||
| 	} | ||||
| 	for _, status := range ctx.FormStrings("status") { | ||||
| 		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.FormString("event"); event != "" { | ||||
| 		opts.TriggerEvent = webhook.HookEventType(event) | ||||
| 	} | ||||
| 	if branch := ctx.FormString("branch"); branch != "" { | ||||
| 		opts.Ref = string(git.RefNameFromBranch(branch)) | ||||
| 	} | ||||
| 	for _, status := range ctx.FormStrings("status") { | ||||
| 		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.FormString("actor"); actor != "" { | ||||
| 		user, err := user_model.GetUserByName(ctx, actor) | ||||
| 		if err != nil { | ||||
| 			ctx.APIErrorInternal(err) | ||||
| 			return | ||||
| 		} | ||||
| 		opts.TriggerUserID = user.ID | ||||
| 	} | ||||
| 	if headSHA := ctx.FormString("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) | ||||
| } | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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" | ||||
| @@ -356,3 +357,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) | ||||
| } | ||||
|   | ||||
| @@ -173,6 +173,7 @@ func updateHookEvents(events []string) webhook_module.HookEvents { | ||||
| 	hookEvents[webhook_module.HookEventRelease] = util.SliceContainsString(events, string(webhook_module.HookEventRelease), true) | ||||
| 	hookEvents[webhook_module.HookEventPackage] = util.SliceContainsString(events, string(webhook_module.HookEventPackage), true) | ||||
| 	hookEvents[webhook_module.HookEventStatus] = util.SliceContainsString(events, string(webhook_module.HookEventStatus), true) | ||||
| 	hookEvents[webhook_module.HookEventWorkflowRun] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowRun), true) | ||||
| 	hookEvents[webhook_module.HookEventWorkflowJob] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowJob), true) | ||||
|  | ||||
| 	// Issues | ||||
|   | ||||
| @@ -304,7 +304,7 @@ func ViewPost(ctx *context_module.Context) { | ||||
| 	if task != nil { | ||||
| 		steps, logs, err := convertToViewModel(ctx, req.LogCursors, task) | ||||
| 		if err != nil { | ||||
| 			ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 			ctx.ServerError("convertToViewModel", err) | ||||
| 			return | ||||
| 		} | ||||
| 		resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, steps...) | ||||
| @@ -408,7 +408,7 @@ func Rerun(ctx *context_module.Context) { | ||||
|  | ||||
| 	run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) | ||||
| 	if err != nil { | ||||
| 		ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 		ctx.ServerError("GetRunByIndex", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| @@ -426,7 +426,7 @@ func Rerun(ctx *context_module.Context) { | ||||
| 		run.Started = 0 | ||||
| 		run.Stopped = 0 | ||||
| 		if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil { | ||||
| 			ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 			ctx.ServerError("UpdateRun", err) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| @@ -441,7 +441,7 @@ func Rerun(ctx *context_module.Context) { | ||||
| 			// if the job has needs, it should be set to "blocked" status to wait for other jobs | ||||
| 			shouldBlock := len(j.Needs) > 0 | ||||
| 			if err := rerunJob(ctx, j, shouldBlock); err != nil { | ||||
| 				ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 				ctx.ServerError("RerunJob", err) | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
| @@ -455,7 +455,7 @@ func Rerun(ctx *context_module.Context) { | ||||
| 		// jobs other than the specified one should be set to "blocked" status | ||||
| 		shouldBlock := j.JobID != job.JobID | ||||
| 		if err := rerunJob(ctx, j, shouldBlock); err != nil { | ||||
| 			ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 			ctx.ServerError("RerunJob", err) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| @@ -485,7 +485,7 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou | ||||
| 	} | ||||
|  | ||||
| 	actions_service.CreateCommitStatus(ctx, job) | ||||
| 	_ = job.LoadAttributes(ctx) | ||||
| 	actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) | ||||
| 	notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) | ||||
|  | ||||
| 	return nil | ||||
| @@ -547,7 +547,7 @@ func Cancel(ctx *context_module.Context) { | ||||
| 		} | ||||
| 		return nil | ||||
| 	}); err != nil { | ||||
| 		ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 		ctx.ServerError("StopTask", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| @@ -557,7 +557,11 @@ 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] | ||||
| 		actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) | ||||
| 		notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) | ||||
| 	} | ||||
| 	ctx.JSON(http.StatusOK, struct{}{}) | ||||
| } | ||||
|  | ||||
| @@ -593,12 +597,18 @@ func Approve(ctx *context_module.Context) { | ||||
| 		} | ||||
| 		return nil | ||||
| 	}); err != nil { | ||||
| 		ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 		ctx.ServerError("UpdateRunJob", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	actions_service.CreateCommitStatus(ctx, jobs...) | ||||
|  | ||||
| 	if len(updatedjobs) > 0 { | ||||
| 		job := updatedjobs[0] | ||||
| 		actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) | ||||
| 		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) | ||||
| @@ -680,7 +690,7 @@ func ArtifactsDeleteView(ctx *context_module.Context) { | ||||
| 		return | ||||
| 	} | ||||
| 	if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil { | ||||
| 		ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 		ctx.ServerError("SetArtifactNeedDelete", err) | ||||
| 		return | ||||
| 	} | ||||
| 	ctx.JSON(http.StatusOK, struct{}{}) | ||||
| @@ -696,7 +706,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { | ||||
| 			ctx.HTTPError(http.StatusNotFound, err.Error()) | ||||
| 			return | ||||
| 		} | ||||
| 		ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 		ctx.ServerError("GetRunByIndex", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| @@ -705,7 +715,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { | ||||
| 		ArtifactName: artifactName, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 		ctx.ServerError("FindArtifacts", err) | ||||
| 		return | ||||
| 	} | ||||
| 	if len(artifacts) == 0 { | ||||
| @@ -726,7 +736,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { | ||||
| 	if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { | ||||
| 		err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) | ||||
| 		if err != nil { | ||||
| 			ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 			ctx.ServerError("DownloadArtifactV4", err) | ||||
| 			return | ||||
| 		} | ||||
| 		return | ||||
| @@ -739,7 +749,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { | ||||
| 	for _, art := range artifacts { | ||||
| 		f, err := storage.ActionsArtifacts.Open(art.StoragePath) | ||||
| 		if err != nil { | ||||
| 			ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 			ctx.ServerError("ActionsArtifacts.Open", err) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| @@ -747,7 +757,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { | ||||
| 		if art.ContentEncoding == "gzip" { | ||||
| 			r, err = gzip.NewReader(f) | ||||
| 			if err != nil { | ||||
| 				ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 				ctx.ServerError("gzip.NewReader", err) | ||||
| 				return | ||||
| 			} | ||||
| 		} else { | ||||
| @@ -757,11 +767,11 @@ func ArtifactsDownloadView(ctx *context_module.Context) { | ||||
|  | ||||
| 		w, err := writer.Create(art.ArtifactPath) | ||||
| 		if err != nil { | ||||
| 			ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 			ctx.ServerError("writer.Create", err) | ||||
| 			return | ||||
| 		} | ||||
| 		if _, err := io.Copy(w, r); err != nil { | ||||
| 			ctx.HTTPError(http.StatusInternalServerError, err.Error()) | ||||
| 			ctx.ServerError("io.Copy", err) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -42,6 +42,10 @@ func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.Ac | ||||
| 			_ = job.LoadAttributes(ctx) | ||||
| 			notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) | ||||
| 		} | ||||
| 		if len(jobs) > 0 { | ||||
| 			job := jobs[0] | ||||
| 			notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -123,7 +127,7 @@ func CancelAbandonedJobs(ctx context.Context) error { | ||||
| 		} | ||||
| 		CreateCommitStatus(ctx, job) | ||||
| 		if updated { | ||||
| 			_ = job.LoadAttributes(ctx) | ||||
| 			NotifyWorkflowRunStatusUpdateWithReload(ctx, job) | ||||
| 			notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) | ||||
| 		} | ||||
| 	} | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import ( | ||||
| 	actions_model "code.gitea.io/gitea/models/actions" | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	"code.gitea.io/gitea/modules/graceful" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/queue" | ||||
| 	notify_service "code.gitea.io/gitea/services/notify" | ||||
|  | ||||
| @@ -78,9 +79,30 @@ func checkJobsOfRun(ctx context.Context, runID int64) error { | ||||
| 		_ = job.LoadAttributes(ctx) | ||||
| 		notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) | ||||
| 	} | ||||
| 	if len(jobs) > 0 { | ||||
| 		runUpdated := true | ||||
| 		for _, job := range jobs { | ||||
| 			if !job.Status.IsDone() { | ||||
| 				runUpdated = false | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 		if runUpdated { | ||||
| 			NotifyWorkflowRunStatusUpdateWithReload(ctx, jobs[0]) | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, job *actions_model.ActionRunJob) { | ||||
| 	job.Run = nil | ||||
| 	if err := job.LoadAttributes(ctx); err != nil { | ||||
| 		log.Error("LoadAttributes: %v", err) | ||||
| 		return | ||||
| 	} | ||||
| 	notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) | ||||
| } | ||||
|  | ||||
| type jobStatusResolver struct { | ||||
| 	statuses map[int64]actions_model.Status | ||||
| 	needs    map[int64][]int64 | ||||
|   | ||||
| @@ -6,13 +6,16 @@ package actions | ||||
| import ( | ||||
| 	"context" | ||||
|  | ||||
| 	actions_model "code.gitea.io/gitea/models/actions" | ||||
| 	issues_model "code.gitea.io/gitea/models/issues" | ||||
| 	"code.gitea.io/gitea/models/organization" | ||||
| 	packages_model "code.gitea.io/gitea/models/packages" | ||||
| 	perm_model "code.gitea.io/gitea/models/perm" | ||||
| 	access_model "code.gitea.io/gitea/models/perm/access" | ||||
| 	repo_model "code.gitea.io/gitea/models/repo" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	"code.gitea.io/gitea/modules/gitrepo" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/repository" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| @@ -762,3 +765,41 @@ func (n *actionsNotifier) MigrateRepository(ctx context.Context, doer, u *user_m | ||||
| 		Sender:       convert.ToUser(ctx, doer, nil), | ||||
| 	}).Notify(ctx) | ||||
| } | ||||
|  | ||||
| func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) { | ||||
| 	ctx = withMethod(ctx, "WorkflowRunStatusUpdate") | ||||
|  | ||||
| 	var org *api.Organization | ||||
| 	if repo.Owner.IsOrganization() { | ||||
| 		org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner)) | ||||
| 	} | ||||
|  | ||||
| 	status := convert.ToWorkflowRunAction(run.Status) | ||||
|  | ||||
| 	gitRepo, err := gitrepo.OpenRepository(ctx, repo) | ||||
| 	if err != nil { | ||||
| 		log.Error("OpenRepository: %v", err) | ||||
| 		return | ||||
| 	} | ||||
| 	defer gitRepo.Close() | ||||
|  | ||||
| 	convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) | ||||
| 	if err != nil { | ||||
| 		log.Error("GetActionWorkflow: %v", err) | ||||
| 		return | ||||
| 	} | ||||
| 	convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run) | ||||
| 	if err != nil { | ||||
| 		log.Error("ToActionWorkflowRun: %v", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	newNotifyInput(repo, sender, webhook_module.HookEventWorkflowRun).WithPayload(&api.WorkflowRunPayload{ | ||||
| 		Action:       status, | ||||
| 		Workflow:     convertedWorkflow, | ||||
| 		WorkflowRun:  convertedRun, | ||||
| 		Organization: org, | ||||
| 		Repo:         convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}), | ||||
| 		Sender:       convert.ToUser(ctx, sender, nil), | ||||
| 	}).Notify(ctx) | ||||
| } | ||||
|   | ||||
| @@ -178,7 +178,7 @@ func notify(ctx context.Context, input *notifyInput) error { | ||||
| 		return fmt.Errorf("gitRepo.GetCommit: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	if skipWorkflows(input, commit) { | ||||
| 	if skipWorkflows(ctx, input, commit) { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| @@ -243,7 +243,7 @@ func notify(ctx context.Context, input *notifyInput) error { | ||||
| 	return handleWorkflows(ctx, detectedWorkflows, commit, input, ref.String()) | ||||
| } | ||||
|  | ||||
| func skipWorkflows(input *notifyInput, commit *git.Commit) bool { | ||||
| func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit) bool { | ||||
| 	// skip workflow runs with a configured skip-ci string in commit message or pr title if the event is push or pull_request(_sync) | ||||
| 	// https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs | ||||
| 	skipWorkflowEvents := []webhook_module.HookEventType{ | ||||
| @@ -263,6 +263,27 @@ func skipWorkflows(input *notifyInput, commit *git.Commit) bool { | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	if input.Event == webhook_module.HookEventWorkflowRun { | ||||
| 		wrun, ok := input.Payload.(*api.WorkflowRunPayload) | ||||
| 		for i := 0; i < 5 && ok && wrun.WorkflowRun != nil; i++ { | ||||
| 			if wrun.WorkflowRun.Event != "workflow_run" { | ||||
| 				return false | ||||
| 			} | ||||
| 			r, 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) | ||||
| 				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 | ||||
| } | ||||
|  | ||||
| @@ -372,6 +393,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) | ||||
| 		} | ||||
|   | ||||
| @@ -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) | ||||
| 	} | ||||
|   | ||||
| @@ -5,9 +5,6 @@ package actions | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"path" | ||||
| 	"strings" | ||||
|  | ||||
| 	actions_model "code.gitea.io/gitea/models/actions" | ||||
| @@ -31,51 +28,8 @@ import ( | ||||
| 	"github.com/nektos/act/pkg/model" | ||||
| ) | ||||
|  | ||||
| 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 | ||||
| 	} | ||||
| @@ -92,42 +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 | ||||
| 	} | ||||
|  | ||||
| 	folder, entries, err := actions.ListWorkflows(defaultBranchCommit) | ||||
| 	if err != nil { | ||||
| 		ctx.APIError(http.StatusNotFound, err.Error()) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	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( | ||||
| @@ -285,6 +203,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) | ||||
| 	} | ||||
|   | ||||
| @@ -12,6 +12,8 @@ import ( | ||||
| ) | ||||
|  | ||||
| // FormString returns the first value matching the provided key in the form as a string | ||||
| // It works the same as http.Request.FormValue: | ||||
| // try urlencoded request body first, then query string, then multipart form body | ||||
| func (b *Base) FormString(key string, def ...string) string { | ||||
| 	s := b.Req.FormValue(key) | ||||
| 	if s == "" { | ||||
| @@ -20,7 +22,7 @@ func (b *Base) FormString(key string, def ...string) string { | ||||
| 	return s | ||||
| } | ||||
|  | ||||
| // FormStrings returns a string slice for the provided key from the form | ||||
| // FormStrings returns a values for the key in the form (including query parameters), similar to FormString | ||||
| func (b *Base) FormStrings(key string) []string { | ||||
| 	if b.Req.Form == nil { | ||||
| 		if err := b.Req.ParseMultipartForm(32 << 20); err != nil { | ||||
|   | ||||
| @@ -5,8 +5,11 @@ | ||||
| package convert | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"net/url" | ||||
| 	"path" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| @@ -14,6 +17,7 @@ import ( | ||||
| 	actions_model "code.gitea.io/gitea/models/actions" | ||||
| 	asymkey_model "code.gitea.io/gitea/models/asymkey" | ||||
| 	"code.gitea.io/gitea/models/auth" | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	git_model "code.gitea.io/gitea/models/git" | ||||
| 	issues_model "code.gitea.io/gitea/models/issues" | ||||
| 	"code.gitea.io/gitea/models/organization" | ||||
| @@ -22,6 +26,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" | ||||
| @@ -32,6 +37,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/services/gitdiff" | ||||
|  | ||||
| 	runnerv1 "code.gitea.io/actions-proto-go/runner/v1" | ||||
| 	"github.com/nektos/act/pkg/model" | ||||
| ) | ||||
|  | ||||
| // ToEmail convert models.EmailAddress to api.Email | ||||
| @@ -241,6 +247,242 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflowRun, error) { | ||||
| 	err := run.LoadAttributes(ctx) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	status, conclusion := ToActionsStatus(run.Status) | ||||
| 	return &api.ActionWorkflowRun{ | ||||
| 		ID:           run.ID, | ||||
| 		URL:          fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID), | ||||
| 		HTMLURL:      run.HTMLURL(), | ||||
| 		RunNumber:    run.Index, | ||||
| 		StartedAt:    run.Started.AsLocalTime(), | ||||
| 		CompletedAt:  run.Stopped.AsLocalTime(), | ||||
| 		Event:        string(run.Event), | ||||
| 		DisplayTitle: run.Title, | ||||
| 		HeadBranch:   git.RefName(run.Ref).BranchName(), | ||||
| 		HeadSha:      run.CommitSHA, | ||||
| 		Status:       status, | ||||
| 		Conclusion:   conclusion, | ||||
| 		Path:         fmt.Sprintf("%s@%s", run.WorkflowID, run.Ref), | ||||
| 		Repository:   ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}), | ||||
| 		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 | ||||
| } | ||||
|  | ||||
| func ToWorkflowRunAction(status actions_model.Status) string { | ||||
| 	var action string | ||||
| 	switch status { | ||||
| 	case actions_model.StatusWaiting, actions_model.StatusBlocked: | ||||
| 		action = "requested" | ||||
| 	case actions_model.StatusRunning: | ||||
| 		action = "in_progress" | ||||
| 	} | ||||
| 	if status.IsDone() { | ||||
| 		action = "completed" | ||||
| 	} | ||||
| 	return action | ||||
| } | ||||
|  | ||||
| func ToActionsStatus(status actions_model.Status) (string, string) { | ||||
| 	var action string | ||||
| 	var conclusion string | ||||
| 	switch status { | ||||
| 	// This is a naming conflict of the webhook between Gitea and GitHub Actions | ||||
| 	case actions_model.StatusWaiting: | ||||
| 		action = "queued" | ||||
| 	case actions_model.StatusBlocked: | ||||
| 		action = "waiting" | ||||
| 	case actions_model.StatusRunning: | ||||
| 		action = "in_progress" | ||||
| 	} | ||||
| 	if status.IsDone() { | ||||
| 		action = "completed" | ||||
| 		switch status { | ||||
| 		case actions_model.StatusSuccess: | ||||
| 			conclusion = "success" | ||||
| 		case actions_model.StatusCancelled: | ||||
| 			conclusion = "cancelled" | ||||
| 		case actions_model.StatusFailure: | ||||
| 			conclusion = "failure" | ||||
| 		case actions_model.StatusSkipped: | ||||
| 			conclusion = "skipped" | ||||
| 		} | ||||
| 	} | ||||
| 	return action, conclusion | ||||
| } | ||||
|  | ||||
| // ToActionWorkflowJob convert a actions_model.ActionRunJob to an api.ActionWorkflowJob | ||||
| // task is optional and can be nil | ||||
| func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task *actions_model.ActionTask, job *actions_model.ActionRunJob) (*api.ActionWorkflowJob, error) { | ||||
| 	err := job.LoadAttributes(ctx) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	jobIndex := 0 | ||||
| 	jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	for i, j := range jobs { | ||||
| 		if j.ID == job.ID { | ||||
| 			jobIndex = i | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	status, conclusion := ToActionsStatus(job.Status) | ||||
| 	var runnerID int64 | ||||
| 	var runnerName string | ||||
| 	var steps []*api.ActionWorkflowStep | ||||
|  | ||||
| 	if job.TaskID != 0 { | ||||
| 		if task == nil { | ||||
| 			task, _, err = db.GetByID[actions_model.ActionTask](ctx, job.TaskID) | ||||
| 			if err != nil { | ||||
| 				return nil, err | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		runnerID = task.RunnerID | ||||
| 		if runner, ok, _ := db.GetByID[actions_model.ActionRunner](ctx, runnerID); ok { | ||||
| 			runnerName = runner.Name | ||||
| 		} | ||||
| 		for i, step := range task.Steps { | ||||
| 			stepStatus, stepConclusion := ToActionsStatus(job.Status) | ||||
| 			steps = append(steps, &api.ActionWorkflowStep{ | ||||
| 				Name:        step.Name, | ||||
| 				Number:      int64(i), | ||||
| 				Status:      stepStatus, | ||||
| 				Conclusion:  stepConclusion, | ||||
| 				StartedAt:   step.Started.AsTime().UTC(), | ||||
| 				CompletedAt: step.Stopped.AsTime().UTC(), | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return &api.ActionWorkflowJob{ | ||||
| 		ID: job.ID, | ||||
| 		// missing api endpoint for this location | ||||
| 		URL:     fmt.Sprintf("%s/actions/jobs/%d", repo.APIURL(), job.ID), | ||||
| 		HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(), jobIndex), | ||||
| 		RunID:   job.RunID, | ||||
| 		// Missing api endpoint for this location, artifacts are available under a nested url | ||||
| 		RunURL:      fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), job.RunID), | ||||
| 		Name:        job.Name, | ||||
| 		Labels:      job.RunsOn, | ||||
| 		RunAttempt:  job.Attempt, | ||||
| 		HeadSha:     job.Run.CommitSHA, | ||||
| 		HeadBranch:  git.RefName(job.Run.Ref).BranchName(), | ||||
| 		Status:      status, | ||||
| 		Conclusion:  conclusion, | ||||
| 		RunnerID:    runnerID, | ||||
| 		RunnerName:  runnerName, | ||||
| 		Steps:       steps, | ||||
| 		CreatedAt:   job.Created.AsTime().UTC(), | ||||
| 		StartedAt:   job.Started.AsTime().UTC(), | ||||
| 		CompletedAt: job.Stopped.AsTime().UTC(), | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func 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(), util.PathEscapeSegments(entry.Name())) | ||||
| 	workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", repo.HTMLURL(ctx), util.PathEscapeSegments(defaultBranch), util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name())) | ||||
| 	badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", repo.HTMLURL(ctx), util.PathEscapeSegments(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 | ||||
|  | ||||
| 	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:      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 | ||||
| 	} | ||||
|  | ||||
| 	folder, entries, err := actions.ListWorkflows(defaultBranchCommit) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	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.ID == 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) | ||||
|   | ||||
| @@ -234,6 +234,7 @@ type WebhookForm struct { | ||||
| 	Release                  bool | ||||
| 	Package                  bool | ||||
| 	Status                   bool | ||||
| 	WorkflowRun              bool | ||||
| 	WorkflowJob              bool | ||||
| 	Active                   bool | ||||
| 	BranchFilter             string `binding:"GlobPattern"` | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) { | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -278,6 +278,12 @@ func (d discordConvertor) Status(p *api.CommitStatusPayload) (DiscordPayload, er | ||||
| 	return d.createPayload(p.Sender, text, "", p.TargetURL, color), nil | ||||
| } | ||||
|  | ||||
| func (d discordConvertor) WorkflowRun(p *api.WorkflowRunPayload) (DiscordPayload, error) { | ||||
| 	text, color := getWorkflowRunPayloadInfo(p, noneLinkFormatter, false) | ||||
|  | ||||
| 	return d.createPayload(p.Sender, text, "", p.WorkflowRun.HTMLURL, color), nil | ||||
| } | ||||
|  | ||||
| func (d discordConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DiscordPayload, error) { | ||||
| 	text, color := getWorkflowJobPayloadInfo(p, noneLinkFormatter, false) | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -327,6 +327,37 @@ func getStatusPayloadInfo(p *api.CommitStatusPayload, linkFormatter linkFormatte | ||||
| 	return text, color | ||||
| } | ||||
|  | ||||
| func getWorkflowRunPayloadInfo(p *api.WorkflowRunPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) { | ||||
| 	description := p.WorkflowRun.Conclusion | ||||
| 	if description == "" { | ||||
| 		description = p.WorkflowRun.Status | ||||
| 	} | ||||
| 	refLink := linkFormatter(p.WorkflowRun.HTMLURL, fmt.Sprintf("%s(#%d)", p.WorkflowRun.DisplayTitle, p.WorkflowRun.ID)+"["+base.ShortSha(p.WorkflowRun.HeadSha)+"]:"+description) | ||||
|  | ||||
| 	text = fmt.Sprintf("Workflow Run %s: %s", p.Action, refLink) | ||||
| 	switch description { | ||||
| 	case "waiting": | ||||
| 		color = orangeColor | ||||
| 	case "queued": | ||||
| 		color = orangeColorLight | ||||
| 	case "success": | ||||
| 		color = greenColor | ||||
| 	case "failure": | ||||
| 		color = redColor | ||||
| 	case "cancelled": | ||||
| 		color = yellowColor | ||||
| 	case "skipped": | ||||
| 		color = purpleColor | ||||
| 	default: | ||||
| 		color = greyColor | ||||
| 	} | ||||
| 	if withSender { | ||||
| 		text += " by " + 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 == "" { | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -318,6 +318,20 @@ func (m msteamsConvertor) Status(p *api.CommitStatusPayload) (MSTeamsPayload, er | ||||
| 	), nil | ||||
| } | ||||
|  | ||||
| func (msteamsConvertor) WorkflowRun(p *api.WorkflowRunPayload) (MSTeamsPayload, error) { | ||||
| 	title, color := getWorkflowRunPayloadInfo(p, noneLinkFormatter, false) | ||||
|  | ||||
| 	return createMSTeamsPayload( | ||||
| 		p.Repo, | ||||
| 		p.Sender, | ||||
| 		title, | ||||
| 		"", | ||||
| 		p.WorkflowRun.HTMLURL, | ||||
| 		color, | ||||
| 		&MSTeamsFact{"WorkflowRun:", p.WorkflowRun.DisplayTitle}, | ||||
| 	), nil | ||||
| } | ||||
|  | ||||
| func (msteamsConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MSTeamsPayload, error) { | ||||
| 	title, color := getWorkflowJobPayloadInfo(p, noneLinkFormatter, false) | ||||
|  | ||||
|   | ||||
| @@ -5,10 +5,8 @@ package webhook | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
|  | ||||
| 	actions_model "code.gitea.io/gitea/models/actions" | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	git_model "code.gitea.io/gitea/models/git" | ||||
| 	issues_model "code.gitea.io/gitea/models/issues" | ||||
| 	"code.gitea.io/gitea/models/organization" | ||||
| @@ -18,6 +16,7 @@ import ( | ||||
| 	repo_model "code.gitea.io/gitea/models/repo" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	"code.gitea.io/gitea/modules/gitrepo" | ||||
| 	"code.gitea.io/gitea/modules/httplib" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/repository" | ||||
| @@ -956,72 +955,17 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_ | ||||
| 		org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner)) | ||||
| 	} | ||||
|  | ||||
| 	err := job.LoadAttributes(ctx) | ||||
| 	status, _ := convert.ToActionsStatus(job.Status) | ||||
|  | ||||
| 	convertedJob, err := convert.ToActionWorkflowJob(ctx, repo, task, job) | ||||
| 	if err != nil { | ||||
| 		log.Error("Error loading job attributes: %v", err) | ||||
| 		log.Error("ToActionWorkflowJob: %v", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	jobIndex := 0 | ||||
| 	jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID) | ||||
| 	if err != nil { | ||||
| 		log.Error("Error loading getting run jobs: %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), | ||||
| @@ -1030,28 +974,46 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_ | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 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" | ||||
| 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, | ||||
| 	} | ||||
| 	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" | ||||
| 		} | ||||
|  | ||||
| 	var org *api.Organization | ||||
| 	if repo.Owner.IsOrganization() { | ||||
| 		org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner)) | ||||
| 	} | ||||
|  | ||||
| 	status := convert.ToWorkflowRunAction(run.Status) | ||||
|  | ||||
| 	gitRepo, err := gitrepo.OpenRepository(ctx, repo) | ||||
| 	if err != nil { | ||||
| 		log.Error("OpenRepository: %v", err) | ||||
| 		return | ||||
| 	} | ||||
| 	defer gitRepo.Close() | ||||
|  | ||||
| 	convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) | ||||
| 	if err != nil { | ||||
| 		log.Error("GetActionWorkflow: %v", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run) | ||||
| 	if err != nil { | ||||
| 		log.Error("ToActionWorkflowRun: %v", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowRun, &api.WorkflowRunPayload{ | ||||
| 		Action:       status, | ||||
| 		Workflow:     convertedWorkflow, | ||||
| 		WorkflowRun:  convertedRun, | ||||
| 		Organization: org, | ||||
| 		Repo:         convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}), | ||||
| 		Sender:       convert.ToUser(ctx, sender, nil), | ||||
| 	}); err != nil { | ||||
| 		log.Error("PrepareWebhooks: %v", err) | ||||
| 	} | ||||
| 	return action, conclusion | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -29,6 +29,7 @@ type payloadConvertor[T any] interface { | ||||
| 	Wiki(*api.WikiPayload) (T, error) | ||||
| 	Package(*api.PackagePayload) (T, error) | ||||
| 	Status(*api.CommitStatusPayload) (T, error) | ||||
| 	WorkflowRun(*api.WorkflowRunPayload) (T, error) | ||||
| 	WorkflowJob(*api.WorkflowJobPayload) (T, error) | ||||
| } | ||||
|  | ||||
| @@ -81,6 +82,8 @@ func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module | ||||
| 		return convertUnmarshalledJSON(rc.Package, data) | ||||
| 	case webhook_module.HookEventStatus: | ||||
| 		return convertUnmarshalledJSON(rc.Status, data) | ||||
| 	case webhook_module.HookEventWorkflowRun: | ||||
| 		return convertUnmarshalledJSON(rc.WorkflowRun, data) | ||||
| 	case webhook_module.HookEventWorkflowJob: | ||||
| 		return convertUnmarshalledJSON(rc.WorkflowJob, data) | ||||
| 	} | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -263,6 +263,16 @@ | ||||
| 		<div class="fourteen wide column"> | ||||
| 			<label>{{ctx.Locale.Tr "repo.settings.event_header_workflow"}}</label> | ||||
| 		</div> | ||||
| 		<!-- Workflow Run Event --> | ||||
| 		<div class="seven wide column"> | ||||
| 			<div class="field"> | ||||
| 				<div class="ui checkbox"> | ||||
| 					<input name="workflow_run" type="checkbox" {{if .Webhook.HookEvents.Get "workflow_run"}}checked{{end}}> | ||||
| 					<label>{{ctx.Locale.Tr "repo.settings.event_workflow_run"}}</label> | ||||
| 					<span class="help">{{ctx.Locale.Tr "repo.settings.event_workflow_run_desc"}}</span> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<!-- Workflow Job Event --> | ||||
| 		<div class="seven wide column"> | ||||
| 			<div class="field"> | ||||
|   | ||||
							
								
								
									
										892
									
								
								templates/swagger/v1_json.tmpl
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										892
									
								
								templates/swagger/v1_json.tmpl
									
									
									
										generated
									
									
									
								
							| @@ -75,6 +75,49 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/admin/actions/jobs": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
|           "application/json" | ||||
|         ], | ||||
|         "tags": [ | ||||
|           "admin" | ||||
|         ], | ||||
|         "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" | ||||
|           }, | ||||
|           "400": { | ||||
|             "$ref": "#/responses/error" | ||||
|           }, | ||||
|           "404": { | ||||
|             "$ref": "#/responses/notFound" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/admin/actions/runners": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
| @@ -177,6 +220,73 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/admin/actions/runs": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
|           "application/json" | ||||
|         ], | ||||
|         "tags": [ | ||||
|           "admin" | ||||
|         ], | ||||
|         "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": "string", | ||||
|             "description": "triggering sha of the workflow run", | ||||
|             "name": "head_sha", | ||||
|             "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" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/admin/cron": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
| @@ -1799,6 +1909,56 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/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 | ||||
|           }, | ||||
|           { | ||||
|             "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" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/orgs/{org}/actions/runners": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
| @@ -1957,6 +2117,80 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/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 | ||||
|           }, | ||||
|           { | ||||
|             "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": "string", | ||||
|             "description": "triggering sha of the workflow run", | ||||
|             "name": "head_sha", | ||||
|             "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" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/orgs/{org}/actions/secrets": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
| @@ -4519,6 +4753,109 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/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 | ||||
|           }, | ||||
|           { | ||||
|             "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" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/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/WorkflowJob" | ||||
|           }, | ||||
|           "400": { | ||||
|             "$ref": "#/responses/error" | ||||
|           }, | ||||
|           "404": { | ||||
|             "$ref": "#/responses/notFound" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/repos/{owner}/{repo}/actions/jobs/{job_id}/logs": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
| @@ -4758,7 +5095,132 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/repos/{owner}/{repo}/actions/runs": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
|           "application/json" | ||||
|         ], | ||||
|         "tags": [ | ||||
|           "repository" | ||||
|         ], | ||||
|         "summary": "Lists all runs for a repository run", | ||||
|         "operationId": "getWorkflowRuns", | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "type": "string", | ||||
|             "description": "name of the owner", | ||||
|             "name": "owner", | ||||
|             "in": "path", | ||||
|             "required": true | ||||
|           }, | ||||
|           { | ||||
|             "type": "string", | ||||
|             "description": "name of the repository", | ||||
|             "name": "repo", | ||||
|             "in": "path", | ||||
|             "required": true | ||||
|           }, | ||||
|           { | ||||
|             "type": "string", | ||||
|             "description": "workflow event name", | ||||
|             "name": "event", | ||||
|             "in": "query" | ||||
|           }, | ||||
|           { | ||||
|             "type": "string", | ||||
|             "description": "workflow branch", | ||||
|             "name": "branch", | ||||
|             "in": "query" | ||||
|           }, | ||||
|           { | ||||
|             "type": "string", | ||||
|             "description": "workflow status (pending, queued, in_progress, failure, success, skipped)", | ||||
|             "name": "status", | ||||
|             "in": "query" | ||||
|           }, | ||||
|           { | ||||
|             "type": "string", | ||||
|             "description": "triggered by user", | ||||
|             "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)", | ||||
|             "name": "page", | ||||
|             "in": "query" | ||||
|           }, | ||||
|           { | ||||
|             "type": "integer", | ||||
|             "description": "page size of results", | ||||
|             "name": "limit", | ||||
|             "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/WorkflowRun" | ||||
|           }, | ||||
|           "400": { | ||||
|             "$ref": "#/responses/error" | ||||
|           }, | ||||
|           "404": { | ||||
|             "$ref": "#/responses/notFound" | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       "delete": { | ||||
|         "produces": [ | ||||
|           "application/json" | ||||
| @@ -4856,6 +5318,70 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/repos/{owner}/{repo}/actions/runs/{run}/jobs": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
|           "application/json" | ||||
|         ], | ||||
|         "tags": [ | ||||
|           "repository" | ||||
|         ], | ||||
|         "summary": "Lists all jobs for a workflow run", | ||||
|         "operationId": "listWorkflowRunJobs", | ||||
|         "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": "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" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/repos/{owner}/{repo}/actions/secrets": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
| @@ -17584,6 +18110,49 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/user/actions/jobs": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
|           "application/json" | ||||
|         ], | ||||
|         "tags": [ | ||||
|           "user" | ||||
|         ], | ||||
|         "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" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/user/actions/runners": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
| @@ -17701,6 +18270,73 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/user/actions/runs": { | ||||
|       "get": { | ||||
|         "produces": [ | ||||
|           "application/json" | ||||
|         ], | ||||
|         "tags": [ | ||||
|           "user" | ||||
|         ], | ||||
|         "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": "string", | ||||
|             "description": "triggering sha of the workflow run", | ||||
|             "name": "head_sha", | ||||
|             "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" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "/user/actions/secrets/{secretname}": { | ||||
|       "put": { | ||||
|         "consumes": [ | ||||
| @@ -20440,23 +21076,251 @@ | ||||
|       }, | ||||
|       "x-go-package": "code.gitea.io/gitea/modules/structs" | ||||
|     }, | ||||
|     "ActionWorkflowRun": { | ||||
|       "description": "ActionWorkflowRun represents a WorkflowRun", | ||||
|     "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", | ||||
|       "properties": { | ||||
|         "actor": { | ||||
|           "$ref": "#/definitions/User" | ||||
|         }, | ||||
|         "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" | ||||
|         }, | ||||
|         "trigger_actor": { | ||||
|           "$ref": "#/definitions/User" | ||||
|         }, | ||||
|         "url": { | ||||
|           "type": "string", | ||||
|           "x-go-name": "URL" | ||||
|         } | ||||
|       }, | ||||
|       "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" | ||||
| @@ -28615,6 +29479,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" | ||||
|     }, | ||||
|   | ||||
| @@ -720,7 +720,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: | ||||
| @@ -800,7 +800,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: | ||||
| @@ -891,7 +891,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: | ||||
| @@ -977,7 +977,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: | ||||
| @@ -1071,7 +1071,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: | ||||
| @@ -1107,7 +1107,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: | ||||
| @@ -1209,7 +1209,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: | ||||
|   | ||||
| @@ -910,8 +910,7 @@ jobs: | ||||
| 		assert.Equal(t, commitID, payloads[3].WorkflowJob.HeadSha) | ||||
| 		assert.Equal(t, "repo1", payloads[3].Repo.Name) | ||||
| 		assert.Equal(t, "user2/repo1", payloads[3].Repo.FullName) | ||||
| 		assert.Contains(t, payloads[3].WorkflowJob.URL, fmt.Sprintf("/actions/runs/%d/jobs/%d", payloads[3].WorkflowJob.RunID, payloads[3].WorkflowJob.ID)) | ||||
| 		assert.Contains(t, payloads[3].WorkflowJob.URL, payloads[3].WorkflowJob.RunURL) | ||||
| 		assert.Contains(t, payloads[3].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[3].WorkflowJob.ID)) | ||||
| 		assert.Contains(t, payloads[3].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 0)) | ||||
| 		assert.Len(t, payloads[3].WorkflowJob.Steps, 1) | ||||
|  | ||||
| @@ -947,9 +946,207 @@ jobs: | ||||
| 		assert.Equal(t, commitID, payloads[6].WorkflowJob.HeadSha) | ||||
| 		assert.Equal(t, "repo1", payloads[6].Repo.Name) | ||||
| 		assert.Equal(t, "user2/repo1", payloads[6].Repo.FullName) | ||||
| 		assert.Contains(t, payloads[6].WorkflowJob.URL, fmt.Sprintf("/actions/runs/%d/jobs/%d", payloads[6].WorkflowJob.RunID, payloads[6].WorkflowJob.ID)) | ||||
| 		assert.Contains(t, payloads[6].WorkflowJob.URL, payloads[6].WorkflowJob.RunURL) | ||||
| 		assert.Contains(t, payloads[6].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[6].WorkflowJob.ID)) | ||||
| 		assert.Contains(t, payloads[6].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 1)) | ||||
| 		assert.Len(t, payloads[6].WorkflowJob.Steps, 2) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| 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, | ||||
| 	} | ||||
| 	runner.execTask(t, task, outcome) | ||||
|  | ||||
| 	task = runner.fetchTask(t) | ||||
| 	outcome = &mockTaskOutcome{ | ||||
| 		result: runnerv1.Result_RESULT_FAILURE, | ||||
| 	} | ||||
| 	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 := range 6 { | ||||
| 		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) | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										167
									
								
								tests/integration/workflow_run_api_check_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								tests/integration/workflow_run_api_check_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,167 @@ | ||||
| // Copyright 2025 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
|  | ||||
| package integration | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"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 TestAPIWorkflowRun(t *testing.T) { | ||||
| 	t.Run("AdminRuns", func(t *testing.T) { | ||||
| 		testAPIWorkflowRunBasic(t, "/api/v1/admin/actions", "User1", 802, auth_model.AccessTokenScopeReadAdmin, 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", "User1", 802, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository) | ||||
| 	}) | ||||
| 	t.Run("RepoRuns", func(t *testing.T) { | ||||
| 		testAPIWorkflowRunBasic(t, "/api/v1/repos/org3/repo5/actions", "User2", 802, auth_model.AccessTokenScopeReadRepository) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) { | ||||
| 	defer tests.PrepareTestEnv(t)() | ||||
| 	token := getUserToken(t, userUsername, scope...) | ||||
|  | ||||
| 	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) | ||||
|  | ||||
| 	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, "", "") | ||||
| 		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) | ||||
|  | ||||
| 		// 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) | ||||
|  | ||||
| 		if run.ID == runID { | ||||
| 			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, "") | ||||
|  | ||||
| 				// Verify job url works | ||||
| 				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.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, headSHA 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) | ||||
| 	} | ||||
| 	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{} | ||||
| 	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) | ||||
| 		} | ||||
| 		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) | ||||
| } | ||||
|  | ||||
| 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) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user