Add support for workflow_dispatch (#3334)
Closes #2797 I'm aware of https://github.com/go-gitea/gitea/pull/28163 exists, but since I had it laying around on my drive and collecting dust, I might as well open a PR for it if anyone wants the feature a bit sooner than waiting for upstream to release it or to be a forgejo "native" implementation. This PR Contains: - Support for the `workflow_dispatch` trigger - Inputs: boolean, string, number, choice Things still to be done: - [x] API Endpoint `/api/v1/<org>/<repo>/actions/workflows/<workflow id>/dispatches` - ~~Fixing some UI bugs I had no time figuring out, like why dropdown/choice inputs's menu's behave weirdly~~ Unrelated visual bug with dropdowns inside dropdowns - [x] Fix bug where opening the branch selection submits the form - [x] Limit on inputs to render/process Things not in this PR: - Inputs: environment (First need support for environments in forgejo) Things needed to test this: - A patch for https://code.forgejo.org/forgejo/runner to actually consider the inputs inside the workflow. ~~One possible patch can be seen here: https://code.forgejo.org/Mai-Lapyst/runner/src/branch/support-workflow-inputs~~ [PR](https://code.forgejo.org/forgejo/runner/pulls/199)  ## Testing - Checkout PR - Setup new development runner with [this PR](https://code.forgejo.org/forgejo/runner/pulls/199) - Create a repo with a workflow (see below) - Go to the actions tab, select the workflow and see the notice as in the screenshot above - Use the button + dropdown to run the workflow - Try also running it via the api using the `` endpoint - ... - Profit! <details> <summary>Example workflow</summary> ```yaml on: workflow_dispatch: inputs: logLevel: description: 'Log Level' required: true default: 'warning' type: choice options: - info - warning - debug tags: description: 'Test scenario tags' required: false type: boolean boolean_default_true: description: 'Test scenario tags' required: true type: boolean default: true boolean_default_false: description: 'Test scenario tags' required: false type: boolean default: false number1_default: description: 'Number w. default' default: '100' type: number number2: description: 'Number w/o. default' type: number string1_default: description: 'String w. default' default: 'Hello world' type: string string2: description: 'String w/o. default' required: true type: string jobs: test: runs-on: docker steps: - uses: actions/checkout@v3 - run: whoami - run: cat /etc/issue - run: uname -a - run: date - run: echo ${{ inputs.logLevel }} - run: echo ${{ inputs.tags }} - env: GITHUB_CONTEXT: ${{ toJson(github) }} run: echo "$GITHUB_CONTEXT" - run: echo "abc" ``` </details> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/3334 Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org> Co-authored-by: Mai-Lapyst <mai-lapyst@noreply.codeberg.org> Co-committed-by: Mai-Lapyst <mai-lapyst@noreply.codeberg.org>
This commit is contained in:
		
							parent
							
								
									544cbc6f01
								
							
						
					
					
						commit
						51735c415b
					
				
					 39 changed files with 792 additions and 16 deletions
				
			
		| 
						 | 
				
			
			@ -2714,6 +2714,8 @@ LEVEL = Info
 | 
			
		|||
;ABANDONED_JOB_TIMEOUT = 24h
 | 
			
		||||
;; Strings committers can place inside a commit message or PR title to skip executing the corresponding actions workflow
 | 
			
		||||
;SKIP_WORKFLOW_STRINGS = [skip ci],[ci skip],[no ci],[skip actions],[actions skip]
 | 
			
		||||
;; Limit on inputs for manual / workflow_dispatch triggers, default is 10
 | 
			
		||||
;LIMIT_DISPATCH_INPUTS = 10
 | 
			
		||||
 | 
			
		||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 | 
			
		||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -750,3 +750,41 @@
 | 
			
		|||
  type: 3
 | 
			
		||||
  config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}"
 | 
			
		||||
  created_unix: 946684810
 | 
			
		||||
 | 
			
		||||
-
 | 
			
		||||
  id: 108
 | 
			
		||||
  repo_id: 62
 | 
			
		||||
  type: 1
 | 
			
		||||
  config: "{}"
 | 
			
		||||
  created_unix: 946684810
 | 
			
		||||
 | 
			
		||||
-
 | 
			
		||||
  id: 109
 | 
			
		||||
  repo_id: 62
 | 
			
		||||
  type: 2
 | 
			
		||||
  created_unix: 946684810
 | 
			
		||||
 | 
			
		||||
-
 | 
			
		||||
  id: 110
 | 
			
		||||
  repo_id: 62
 | 
			
		||||
  type: 3
 | 
			
		||||
  created_unix: 946684810
 | 
			
		||||
 | 
			
		||||
-
 | 
			
		||||
  id: 111
 | 
			
		||||
  repo_id: 62
 | 
			
		||||
  type: 4
 | 
			
		||||
  created_unix: 946684810
 | 
			
		||||
 | 
			
		||||
-
 | 
			
		||||
  id: 112
 | 
			
		||||
  repo_id: 62
 | 
			
		||||
  type: 5
 | 
			
		||||
  created_unix: 946684810
 | 
			
		||||
 | 
			
		||||
-
 | 
			
		||||
  id: 113
 | 
			
		||||
  repo_id: 62
 | 
			
		||||
  type: 10
 | 
			
		||||
  config: "{}"
 | 
			
		||||
  created_unix: 946684810
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1782,3 +1782,33 @@
 | 
			
		|||
  size: 0
 | 
			
		||||
  is_fsck_enabled: true
 | 
			
		||||
  close_issues_via_commit_in_any_branch: false
 | 
			
		||||
 | 
			
		||||
- id: 62
 | 
			
		||||
  owner_id: 2
 | 
			
		||||
  owner_name: user2
 | 
			
		||||
  lower_name: test_workflows
 | 
			
		||||
  name: test_workflows
 | 
			
		||||
  default_branch: main
 | 
			
		||||
  num_watches: 0
 | 
			
		||||
  num_stars: 0
 | 
			
		||||
  num_forks: 0
 | 
			
		||||
  num_issues: 0
 | 
			
		||||
  num_closed_issues: 0
 | 
			
		||||
  num_pulls: 0
 | 
			
		||||
  num_closed_pulls: 0
 | 
			
		||||
  num_milestones: 0
 | 
			
		||||
  num_closed_milestones: 0
 | 
			
		||||
  num_projects: 0
 | 
			
		||||
  num_closed_projects: 0
 | 
			
		||||
  is_private: false
 | 
			
		||||
  is_empty: false
 | 
			
		||||
  is_archived: false
 | 
			
		||||
  is_mirror: false
 | 
			
		||||
  status: 0
 | 
			
		||||
  is_fork: false
 | 
			
		||||
  fork_id: 0
 | 
			
		||||
  is_template: false
 | 
			
		||||
  template_id: 0
 | 
			
		||||
  size: 0
 | 
			
		||||
  is_fsck_enabled: true
 | 
			
		||||
  close_issues_via_commit_in_any_branch: false
 | 
			
		||||
| 
						 | 
				
			
			@ -66,7 +66,7 @@
 | 
			
		|||
  num_followers: 2
 | 
			
		||||
  num_following: 1
 | 
			
		||||
  num_stars: 2
 | 
			
		||||
  num_repos: 16
 | 
			
		||||
  num_repos: 17
 | 
			
		||||
  num_teams: 0
 | 
			
		||||
  num_members: 0
 | 
			
		||||
  visibility: 0
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -138,27 +138,27 @@ func getTestCases() []struct {
 | 
			
		|||
		{
 | 
			
		||||
			name:  "AllPublic/PublicRepositoriesOfUserIncludingCollaborative",
 | 
			
		||||
			opts:  &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, AllPublic: true, Template: optional.Some(false)},
 | 
			
		||||
			count: 34,
 | 
			
		||||
			count: 35,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:  "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborative",
 | 
			
		||||
			opts:  &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true, AllLimited: true, Template: optional.Some(false)},
 | 
			
		||||
			count: 39,
 | 
			
		||||
			count: 40,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:  "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborativeByName",
 | 
			
		||||
			opts:  &repo_model.SearchRepoOptions{Keyword: "test", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true},
 | 
			
		||||
			count: 15,
 | 
			
		||||
			count: 16,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:  "AllPublic/PublicAndPrivateRepositoriesOfUser2IncludingCollaborativeByName",
 | 
			
		||||
			opts:  &repo_model.SearchRepoOptions{Keyword: "test", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18, Private: true, AllPublic: true},
 | 
			
		||||
			count: 13,
 | 
			
		||||
			count: 14,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:  "AllPublic/PublicRepositoriesOfOrganization",
 | 
			
		||||
			opts:  &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, AllPublic: true, Collaborate: optional.Some(false), Template: optional.Some(false)},
 | 
			
		||||
			count: 34,
 | 
			
		||||
			count: 35,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:  "AllTemplates",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,6 +23,7 @@ const (
 | 
			
		|||
	GithubEventPullRequestComment       = "pull_request_comment"
 | 
			
		||||
	GithubEventGollum                   = "gollum"
 | 
			
		||||
	GithubEventSchedule                 = "schedule"
 | 
			
		||||
	GithubEventWorkflowDispatch         = "workflow_dispatch"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// IsDefaultBranchWorkflow returns true if the event only triggers workflows on the default branch
 | 
			
		||||
| 
						 | 
				
			
			@ -52,6 +53,10 @@ func IsDefaultBranchWorkflow(triggedEvent webhook_module.HookEventType) bool {
 | 
			
		|||
		// GitHub "schedule" event
 | 
			
		||||
		// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
 | 
			
		||||
		return true
 | 
			
		||||
	case webhook_module.HookEventWorkflowDispatch:
 | 
			
		||||
		// GitHub "workflow_dispatch" event
 | 
			
		||||
		// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch
 | 
			
		||||
		return true
 | 
			
		||||
	case webhook_module.HookEventIssues,
 | 
			
		||||
		webhook_module.HookEventIssueAssign,
 | 
			
		||||
		webhook_module.HookEventIssueLabel,
 | 
			
		||||
| 
						 | 
				
			
			@ -74,6 +79,9 @@ func canGithubEventMatch(eventName string, triggedEvent webhook_module.HookEvent
 | 
			
		|||
	case GithubEventGollum:
 | 
			
		||||
		return triggedEvent == webhook_module.HookEventWiki
 | 
			
		||||
 | 
			
		||||
	case GithubEventWorkflowDispatch:
 | 
			
		||||
		return triggedEvent == webhook_module.HookEventWorkflowDispatch
 | 
			
		||||
 | 
			
		||||
	case GithubEventIssues:
 | 
			
		||||
		switch triggedEvent {
 | 
			
		||||
		case webhook_module.HookEventIssues,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -191,6 +191,7 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
 | 
			
		|||
 | 
			
		||||
	switch triggedEvent {
 | 
			
		||||
	case // events with no activity types
 | 
			
		||||
		webhook_module.HookEventWorkflowDispatch,
 | 
			
		||||
		webhook_module.HookEventCreate,
 | 
			
		||||
		webhook_module.HookEventDelete,
 | 
			
		||||
		webhook_module.HookEventFork,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -125,6 +125,13 @@ func TestDetectMatched(t *testing.T) {
 | 
			
		|||
			yamlOn:       "on: schedule",
 | 
			
		||||
			expected:     true,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			desc:         "HookEventWorkflowDispatch(workflow_dispatch) matches GithubEventWorkflowDispatch(workflow_dispatch)",
 | 
			
		||||
			triggedEvent: webhook_module.HookEventWorkflowDispatch,
 | 
			
		||||
			payload:      nil,
 | 
			
		||||
			yamlOn:       "on: workflow_dispatch",
 | 
			
		||||
			expected:     true,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, tc := range testCases {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,10 +21,12 @@ var (
 | 
			
		|||
		EndlessTaskTimeout    time.Duration     `ini:"ENDLESS_TASK_TIMEOUT"`
 | 
			
		||||
		AbandonedJobTimeout   time.Duration     `ini:"ABANDONED_JOB_TIMEOUT"`
 | 
			
		||||
		SkipWorkflowStrings   []string          `ìni:"SKIP_WORKFLOW_STRINGS"`
 | 
			
		||||
		LimitDispatchInputs   int64             `ini:"LIMIT_DISPATCH_INPUTS"`
 | 
			
		||||
	}{
 | 
			
		||||
		Enabled:             true,
 | 
			
		||||
		DefaultActionsURL:   defaultActionsURLForgejo,
 | 
			
		||||
		SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
 | 
			
		||||
		LimitDispatchInputs: 10,
 | 
			
		||||
	}
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -416,6 +416,14 @@ type SchedulePayload struct {
 | 
			
		|||
	Action HookScheduleAction `json:"action"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type WorkflowDispatchPayload struct {
 | 
			
		||||
	Inputs     map[string]string `json:"inputs"`
 | 
			
		||||
	Ref        string            `json:"ref"`
 | 
			
		||||
	Repository *Repository       `json:"repository"`
 | 
			
		||||
	Sender     *User             `json:"sender"`
 | 
			
		||||
	Workflow   string            `json:"workflow"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ReviewPayload FIXME
 | 
			
		||||
type ReviewPayload struct {
 | 
			
		||||
	Type    string `json:"type"`
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										15
									
								
								modules/structs/workflow.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								modules/structs/workflow.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
// Copyright The Forgejo Authors.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package structs
 | 
			
		||||
 | 
			
		||||
// DispatchWorkflowOption options when dispatching a workflow
 | 
			
		||||
// swagger:model
 | 
			
		||||
type DispatchWorkflowOption struct {
 | 
			
		||||
	// Git reference for the workflow
 | 
			
		||||
	//
 | 
			
		||||
	// required: true
 | 
			
		||||
	Ref string `json:"ref"`
 | 
			
		||||
	// Input keys and values configured in the workflow file.
 | 
			
		||||
	Inputs map[string]string `json:"inputs"`
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -32,6 +32,7 @@ const (
 | 
			
		|||
	HookEventRelease                   HookEventType = "release"
 | 
			
		||||
	HookEventPackage                   HookEventType = "package"
 | 
			
		||||
	HookEventSchedule                  HookEventType = "schedule"
 | 
			
		||||
	HookEventWorkflowDispatch          HookEventType = "workflow_dispatch"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Event returns the HookEventType as an event string
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3769,6 +3769,13 @@ workflow.disable_success = Workflow "%s" disabled successfully.
 | 
			
		|||
workflow.enable = Enable workflow
 | 
			
		||||
workflow.enable_success = Workflow "%s" enabled successfully.
 | 
			
		||||
workflow.disabled = Workflow is disabled.
 | 
			
		||||
workflow.dispatch.trigger_found = This workflow has a <c>workflow_dispatch</c> event trigger.
 | 
			
		||||
workflow.dispatch.use_from = Use workflow from
 | 
			
		||||
workflow.dispatch.run = Run workflow
 | 
			
		||||
workflow.dispatch.success = Workflow run was successfully requested.
 | 
			
		||||
workflow.dispatch.input_required = Require value for input "%s".
 | 
			
		||||
workflow.dispatch.invalid_input_type = Invalid input type "%s".
 | 
			
		||||
workflow.dispatch.warn_input_limit = Only displaying the first %d inputs.
 | 
			
		||||
 | 
			
		||||
need_approval_desc = Need approval to run workflows for fork pull request.
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1
									
								
								release-notes/8.0.0/3334.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								release-notes/8.0.0/3334.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
Added support for the `workflow_dispatch` workflow trigger
 | 
			
		||||
| 
						 | 
				
			
			@ -1123,6 +1123,12 @@ func Routes() *web.Route {
 | 
			
		|||
				}, reqToken(), reqAdmin())
 | 
			
		||||
				m.Group("/actions", func() {
 | 
			
		||||
					m.Get("/tasks", repo.ListActionTasks)
 | 
			
		||||
 | 
			
		||||
					m.Group("/workflows", func() {
 | 
			
		||||
						m.Group("/{workflowname}", func() {
 | 
			
		||||
							m.Post("/dispatches", reqToken(), reqRepoWriter(unit.TypeActions), mustNotBeArchived, bind(api.DispatchWorkflowOption{}), repo.DispatchWorkflow)
 | 
			
		||||
						})
 | 
			
		||||
					})
 | 
			
		||||
				}, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true))
 | 
			
		||||
				m.Group("/keys", func() {
 | 
			
		||||
					m.Combo("").Get(repo.ListDeployKeys).
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -583,3 +583,73 @@ func ListActionTasks(ctx *context.APIContext) {
 | 
			
		|||
 | 
			
		||||
	ctx.JSON(http.StatusOK, &res)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DispatchWorkflow dispatches a workflow
 | 
			
		||||
func DispatchWorkflow(ctx *context.APIContext) {
 | 
			
		||||
	// swagger:operation POST /repos/{owner}/{repo}/actions/workflows/{workflowname}/dispatches repository DispatchWorkflow
 | 
			
		||||
	// ---
 | 
			
		||||
	// summary: Dispatches a workflow
 | 
			
		||||
	// consumes:
 | 
			
		||||
	// - application/json
 | 
			
		||||
	// parameters:
 | 
			
		||||
	// - name: owner
 | 
			
		||||
	//   in: path
 | 
			
		||||
	//   description: owner of the repo
 | 
			
		||||
	//   type: string
 | 
			
		||||
	//   required: true
 | 
			
		||||
	// - name: repo
 | 
			
		||||
	//   in: path
 | 
			
		||||
	//   description: name of the repo
 | 
			
		||||
	//   type: string
 | 
			
		||||
	//   required: true
 | 
			
		||||
	// - name: workflowname
 | 
			
		||||
	//   in: path
 | 
			
		||||
	//   description: name of the workflow
 | 
			
		||||
	//   type: string
 | 
			
		||||
	//   required: true
 | 
			
		||||
	// - name: body
 | 
			
		||||
	//   in: body
 | 
			
		||||
	//   schema:
 | 
			
		||||
	//     "$ref": "#/definitions/DispatchWorkflowOption"
 | 
			
		||||
	// responses:
 | 
			
		||||
	//   "204":
 | 
			
		||||
	//     "$ref": "#/responses/empty"
 | 
			
		||||
	//   "404":
 | 
			
		||||
	//     "$ref": "#/responses/notFound"
 | 
			
		||||
 | 
			
		||||
	opt := web.GetForm(ctx).(*api.DispatchWorkflowOption)
 | 
			
		||||
	name := ctx.Params("workflowname")
 | 
			
		||||
 | 
			
		||||
	if len(opt.Ref) == 0 {
 | 
			
		||||
		ctx.Error(http.StatusBadRequest, "ref", nil)
 | 
			
		||||
		return
 | 
			
		||||
	} else if len(name) == 0 {
 | 
			
		||||
		ctx.Error(http.StatusBadRequest, "workflowname", nil)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	workflow, err := actions_service.GetWorkflowFromCommit(ctx.Repo.GitRepo, opt.Ref, name)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if errors.Is(err, util.ErrNotExist) {
 | 
			
		||||
			ctx.Error(http.StatusNotFound, "GetWorkflowFromCommit", err)
 | 
			
		||||
		} else {
 | 
			
		||||
			ctx.Error(http.StatusInternalServerError, "GetWorkflowFromCommit", err)
 | 
			
		||||
		}
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	inputGetter := func(key string) string {
 | 
			
		||||
		return opt.Inputs[key]
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := workflow.Dispatch(ctx, inputGetter, ctx.Repo.Repository, ctx.Doer); err != nil {
 | 
			
		||||
		if actions_service.IsInputRequiredErr(err) {
 | 
			
		||||
			ctx.Error(http.StatusBadRequest, "workflow.Dispatch", err)
 | 
			
		||||
		} else {
 | 
			
		||||
			ctx.Error(http.StatusInternalServerError, "workflow.Dispatch", err)
 | 
			
		||||
		}
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ctx.JSON(http.StatusNoContent, nil)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -216,4 +216,7 @@ type swaggerParameterBodies struct {
 | 
			
		|||
 | 
			
		||||
	// in:body
 | 
			
		||||
	UpdateVariableOption api.UpdateVariableOption
 | 
			
		||||
 | 
			
		||||
	// in:body
 | 
			
		||||
	DispatchWorkflowOption api.DispatchWorkflowOption
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ import (
 | 
			
		|||
	"bytes"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"slices"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	actions_model "code.gitea.io/gitea/models/actions"
 | 
			
		||||
| 
						 | 
				
			
			@ -18,6 +19,7 @@ import (
 | 
			
		|||
	"code.gitea.io/gitea/modules/git"
 | 
			
		||||
	"code.gitea.io/gitea/modules/optional"
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
	"code.gitea.io/gitea/modules/util"
 | 
			
		||||
	"code.gitea.io/gitea/routers/web/repo"
 | 
			
		||||
	"code.gitea.io/gitea/services/context"
 | 
			
		||||
	"code.gitea.io/gitea/services/convert"
 | 
			
		||||
| 
						 | 
				
			
			@ -59,6 +61,9 @@ func List(ctx *context.Context) {
 | 
			
		|||
	ctx.Data["Title"] = ctx.Tr("actions.actions")
 | 
			
		||||
	ctx.Data["PageIsActions"] = true
 | 
			
		||||
 | 
			
		||||
	curWorkflow := ctx.FormString("workflow")
 | 
			
		||||
	ctx.Data["CurWorkflow"] = curWorkflow
 | 
			
		||||
 | 
			
		||||
	var workflows []Workflow
 | 
			
		||||
	if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil {
 | 
			
		||||
		ctx.ServerError("IsEmpty", err)
 | 
			
		||||
| 
						 | 
				
			
			@ -140,6 +145,22 @@ func List(ctx *context.Context) {
 | 
			
		|||
				workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job")
 | 
			
		||||
			}
 | 
			
		||||
			workflows = append(workflows, workflow)
 | 
			
		||||
 | 
			
		||||
			if workflow.Entry.Name() == curWorkflow {
 | 
			
		||||
				config := wf.WorkflowDispatchConfig()
 | 
			
		||||
				if config != nil {
 | 
			
		||||
					keys := util.KeysOfMap(config.Inputs)
 | 
			
		||||
					slices.Sort(keys)
 | 
			
		||||
					if int64(len(config.Inputs)) > setting.Actions.LimitDispatchInputs {
 | 
			
		||||
						keys = keys[:setting.Actions.LimitDispatchInputs]
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					ctx.Data["CurWorkflowDispatch"] = config
 | 
			
		||||
					ctx.Data["CurWorkflowDispatchInputKeys"] = keys
 | 
			
		||||
					ctx.Data["WarnDispatchInputsLimit"] = int64(len(config.Inputs)) > setting.Actions.LimitDispatchInputs
 | 
			
		||||
					ctx.Data["DispatchInputsLimit"] = setting.Actions.LimitDispatchInputs
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	ctx.Data["workflows"] = workflows
 | 
			
		||||
| 
						 | 
				
			
			@ -150,17 +171,15 @@ func List(ctx *context.Context) {
 | 
			
		|||
		page = 1
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	workflow := ctx.FormString("workflow")
 | 
			
		||||
	actorID := ctx.FormInt64("actor")
 | 
			
		||||
	status := ctx.FormInt("status")
 | 
			
		||||
	ctx.Data["CurWorkflow"] = workflow
 | 
			
		||||
 | 
			
		||||
	actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
 | 
			
		||||
	ctx.Data["ActionsConfig"] = actionsConfig
 | 
			
		||||
 | 
			
		||||
	if len(workflow) > 0 && ctx.Repo.IsAdmin() {
 | 
			
		||||
	if len(curWorkflow) > 0 && ctx.Repo.IsAdmin() {
 | 
			
		||||
		ctx.Data["AllowDisableOrEnableWorkflow"] = true
 | 
			
		||||
		ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(workflow)
 | 
			
		||||
		ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(curWorkflow)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// if status or actor query param is not given to frontend href, (href="/<repoLink>/actions")
 | 
			
		||||
| 
						 | 
				
			
			@ -177,7 +196,7 @@ func List(ctx *context.Context) {
 | 
			
		|||
			PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
 | 
			
		||||
		},
 | 
			
		||||
		RepoID:        ctx.Repo.Repository.ID,
 | 
			
		||||
		WorkflowID:    workflow,
 | 
			
		||||
		WorkflowID:    curWorkflow,
 | 
			
		||||
		TriggerUserID: actorID,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -203,6 +222,8 @@ func List(ctx *context.Context) {
 | 
			
		|||
 | 
			
		||||
	ctx.Data["Runs"] = runs
 | 
			
		||||
 | 
			
		||||
	ctx.Data["Repo"] = ctx.Repo
 | 
			
		||||
 | 
			
		||||
	actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		ctx.ServerError("GetActors", err)
 | 
			
		||||
| 
						 | 
				
			
			@ -214,7 +235,7 @@ func List(ctx *context.Context) {
 | 
			
		|||
 | 
			
		||||
	pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5)
 | 
			
		||||
	pager.SetDefaultParams(ctx)
 | 
			
		||||
	pager.AddParamString("workflow", workflow)
 | 
			
		||||
	pager.AddParamString("workflow", curWorkflow)
 | 
			
		||||
	pager.AddParamString("actor", fmt.Sprint(actorID))
 | 
			
		||||
	pager.AddParamString("status", fmt.Sprint(status))
 | 
			
		||||
	ctx.Data["Page"] = pager
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										62
									
								
								routers/web/repo/actions/manual.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								routers/web/repo/actions/manual.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,62 @@
 | 
			
		|||
// Copyright The Forgejo Authors.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package actions
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/url"
 | 
			
		||||
 | 
			
		||||
	actions_service "code.gitea.io/gitea/services/actions"
 | 
			
		||||
	context_module "code.gitea.io/gitea/services/context"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func ManualRunWorkflow(ctx *context_module.Context) {
 | 
			
		||||
	workflowID := ctx.FormString("workflow")
 | 
			
		||||
	if len(workflowID) == 0 {
 | 
			
		||||
		ctx.ServerError("workflow", nil)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ref := ctx.FormString("ref")
 | 
			
		||||
	if len(ref) == 0 {
 | 
			
		||||
		ctx.ServerError("ref", nil)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil {
 | 
			
		||||
		ctx.ServerError("IsEmpty", err)
 | 
			
		||||
		return
 | 
			
		||||
	} else if empty {
 | 
			
		||||
		ctx.NotFound("IsEmpty", nil)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	workflow, err := actions_service.GetWorkflowFromCommit(ctx.Repo.GitRepo, ref, workflowID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		ctx.ServerError("GetWorkflowFromCommit", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	location := ctx.Repo.RepoLink + "/actions?workflow=" + url.QueryEscape(workflowID) +
 | 
			
		||||
		"&actor=" + url.QueryEscape(ctx.FormString("actor")) +
 | 
			
		||||
		"&status=" + url.QueryEscape(ctx.FormString("status"))
 | 
			
		||||
 | 
			
		||||
	formKeyGetter := func(key string) string {
 | 
			
		||||
		formKey := "inputs[" + key + "]"
 | 
			
		||||
		return ctx.FormString(formKey)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := workflow.Dispatch(ctx, formKeyGetter, ctx.Repo.Repository, ctx.Doer); err != nil {
 | 
			
		||||
		if actions_service.IsInputRequiredErr(err) {
 | 
			
		||||
			ctx.Flash.Error(ctx.Locale.Tr("actions.workflow.dispatch.input_required", err.(actions_service.InputRequiredErr).Name))
 | 
			
		||||
			ctx.Redirect(location)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		ctx.ServerError("workflow.Dispatch", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// forward to the page of the run which was just created
 | 
			
		||||
	ctx.Flash.Info(ctx.Locale.Tr("actions.workflow.dispatch.success"))
 | 
			
		||||
	ctx.Redirect(location)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1376,6 +1376,7 @@ func registerRoutes(m *web.Route) {
 | 
			
		|||
			m.Get("", actions.List)
 | 
			
		||||
			m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile)
 | 
			
		||||
			m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile)
 | 
			
		||||
			m.Post("/manual", reqRepoAdmin, actions.ManualRunWorkflow)
 | 
			
		||||
 | 
			
		||||
			m.Group("/runs", func() {
 | 
			
		||||
				m.Get("/latest", actions.ViewLatest)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										171
									
								
								services/actions/workflows.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								services/actions/workflows.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,171 @@
 | 
			
		|||
// Copyright The Forgejo Authors.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package actions
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"context"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"strconv"
 | 
			
		||||
 | 
			
		||||
	actions_model "code.gitea.io/gitea/models/actions"
 | 
			
		||||
	"code.gitea.io/gitea/models/perm"
 | 
			
		||||
	"code.gitea.io/gitea/models/perm/access"
 | 
			
		||||
	repo_model "code.gitea.io/gitea/models/repo"
 | 
			
		||||
	"code.gitea.io/gitea/models/user"
 | 
			
		||||
	"code.gitea.io/gitea/modules/actions"
 | 
			
		||||
	"code.gitea.io/gitea/modules/git"
 | 
			
		||||
	"code.gitea.io/gitea/modules/json"
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
	"code.gitea.io/gitea/modules/structs"
 | 
			
		||||
	"code.gitea.io/gitea/modules/webhook"
 | 
			
		||||
	"code.gitea.io/gitea/services/convert"
 | 
			
		||||
 | 
			
		||||
	"github.com/nektos/act/pkg/jobparser"
 | 
			
		||||
	act_model "github.com/nektos/act/pkg/model"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type InputRequiredErr struct {
 | 
			
		||||
	Name string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (err InputRequiredErr) Error() string {
 | 
			
		||||
	return fmt.Sprintf("input required for '%s'", err.Name)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func IsInputRequiredErr(err error) bool {
 | 
			
		||||
	_, ok := err.(InputRequiredErr)
 | 
			
		||||
	return ok
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Workflow struct {
 | 
			
		||||
	WorkflowID string
 | 
			
		||||
	Ref        string
 | 
			
		||||
	Commit     *git.Commit
 | 
			
		||||
	GitEntry   *git.TreeEntry
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type InputValueGetter func(key string) string
 | 
			
		||||
 | 
			
		||||
func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGetter, repo *repo_model.Repository, doer *user.User) error {
 | 
			
		||||
	content, err := actions.GetContentFromEntry(entry.GitEntry)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fullWorkflowID := ".forgejo/workflows/" + entry.WorkflowID
 | 
			
		||||
 | 
			
		||||
	title := wf.Name
 | 
			
		||||
	if len(title) < 1 {
 | 
			
		||||
		title = fullWorkflowID
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	inputs := make(map[string]string)
 | 
			
		||||
	if workflowDispatch := wf.WorkflowDispatchConfig(); workflowDispatch != nil {
 | 
			
		||||
		for key, input := range workflowDispatch.Inputs {
 | 
			
		||||
			val := inputGetter(key)
 | 
			
		||||
			if len(val) == 0 {
 | 
			
		||||
				val = input.Default
 | 
			
		||||
				if len(val) == 0 {
 | 
			
		||||
					if input.Required {
 | 
			
		||||
						name := input.Description
 | 
			
		||||
						if len(name) == 0 {
 | 
			
		||||
							name = key
 | 
			
		||||
						}
 | 
			
		||||
						return InputRequiredErr{Name: name}
 | 
			
		||||
					}
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				switch input.Type {
 | 
			
		||||
				case "boolean":
 | 
			
		||||
					// Since "boolean" inputs are rendered as a checkbox in html, the value inside the form is "on"
 | 
			
		||||
					val = strconv.FormatBool(val == "on")
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			inputs[key] = val
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if int64(len(inputs)) > setting.Actions.LimitDispatchInputs {
 | 
			
		||||
		return errors.New("to many inputs")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	payload := &structs.WorkflowDispatchPayload{
 | 
			
		||||
		Inputs:     inputs,
 | 
			
		||||
		Ref:        entry.Ref,
 | 
			
		||||
		Repository: convert.ToRepo(ctx, repo, access.Permission{AccessMode: perm.AccessModeNone}),
 | 
			
		||||
		Sender:     convert.ToUser(ctx, doer, nil),
 | 
			
		||||
		Workflow:   fullWorkflowID,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	p, err := json.Marshal(payload)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	run := &actions_model.ActionRun{
 | 
			
		||||
		Title:         title,
 | 
			
		||||
		RepoID:        repo.ID,
 | 
			
		||||
		Repo:          repo,
 | 
			
		||||
		OwnerID:       repo.OwnerID,
 | 
			
		||||
		WorkflowID:    entry.WorkflowID,
 | 
			
		||||
		TriggerUserID: doer.ID,
 | 
			
		||||
		TriggerUser:   doer,
 | 
			
		||||
		Ref:           entry.Ref,
 | 
			
		||||
		CommitSHA:     entry.Commit.ID.String(),
 | 
			
		||||
		Event:         webhook.HookEventWorkflowDispatch,
 | 
			
		||||
		EventPayload:  string(p),
 | 
			
		||||
		TriggerEvent:  string(webhook.HookEventWorkflowDispatch),
 | 
			
		||||
		Status:        actions_model.StatusWaiting,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	vars, err := actions_model.GetVariablesOfRun(ctx, run)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	jobs, err := jobparser.Parse(content, jobparser.WithVars(vars))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return actions_model.InsertRun(ctx, run, jobs)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func GetWorkflowFromCommit(gitRepo *git.Repository, ref, workflowID string) (*Workflow, error) {
 | 
			
		||||
	commit, err := gitRepo.GetCommit(ref)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	entries, err := actions.ListWorkflows(commit)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var workflowEntry *git.TreeEntry
 | 
			
		||||
	for _, entry := range entries {
 | 
			
		||||
		if entry.Name() == workflowID {
 | 
			
		||||
			workflowEntry = entry
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if workflowEntry == nil {
 | 
			
		||||
		return nil, errors.New("workflow not found")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &Workflow{
 | 
			
		||||
		WorkflowID: workflowID,
 | 
			
		||||
		Ref:        ref,
 | 
			
		||||
		Commit:     commit,
 | 
			
		||||
		GitEntry:   workflowEntry,
 | 
			
		||||
	}, nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										99
									
								
								templates/repo/actions/dispatch.tmpl
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								templates/repo/actions/dispatch.tmpl
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,99 @@
 | 
			
		|||
<div class="ui info message tw-flex tw-items-center">
 | 
			
		||||
	<span>
 | 
			
		||||
		{{ctx.Locale.Tr "actions.workflow.dispatch.trigger_found"}}
 | 
			
		||||
	</span>
 | 
			
		||||
	<div class="ui dropdown custom tw-ml-4" id="workflow_dispatch_dropdown">
 | 
			
		||||
		<button class="ui compact small basic button tw-flex">
 | 
			
		||||
			<span class="text">{{ctx.Locale.Tr "actions.workflow.dispatch.run"}}</span>
 | 
			
		||||
			{{svg "octicon-triangle-down" 14 "dropdown icon"}}
 | 
			
		||||
		</button>
 | 
			
		||||
		<div class="menu">
 | 
			
		||||
			<div class="message ui form">
 | 
			
		||||
				<div class="field">
 | 
			
		||||
					<label>{{ctx.Locale.Tr "actions.workflow.dispatch.use_from"}}</label>
 | 
			
		||||
					{{template "repo/branch_dropdown" dict
 | 
			
		||||
						"root" (dict
 | 
			
		||||
							"IsViewBranch" true
 | 
			
		||||
							"BranchName" .Repo.BranchName
 | 
			
		||||
							"CommitID" .Repo.CommitID
 | 
			
		||||
							"RepoLink" .Repo.RepoLink
 | 
			
		||||
							"Repository" .Repo.Repository
 | 
			
		||||
						)
 | 
			
		||||
						"disableCreateBranch" true
 | 
			
		||||
						"branchForm" "branch-dropdown-form"
 | 
			
		||||
						"setAction" false
 | 
			
		||||
						"submitForm" false
 | 
			
		||||
					}}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<form method="post" action="{{.Repo.RepoLink}}/actions/manual" id="branch-dropdown-form">
 | 
			
		||||
					{{range $i, $key := .CurWorkflowDispatchInputKeys}}
 | 
			
		||||
						{{$val := index $.CurWorkflowDispatch.Inputs $key}}
 | 
			
		||||
						<div class="{{if $val.Required}}required {{end}}field">
 | 
			
		||||
							{{if eq $val.Type "boolean"}}
 | 
			
		||||
								<div class="ui checkbox">
 | 
			
		||||
									<label><strong>{{if $val.Description}}{{$val.Description}}{{else}}{{$key}}{{end}}</strong></label>
 | 
			
		||||
									<input {{if $val.Required}}required{{end}} type="checkbox" name="inputs[{{$key}}]" {{if eq $val.Default "true"}}checked{{end}}>
 | 
			
		||||
								</div>
 | 
			
		||||
							{{else}}
 | 
			
		||||
								<label>{{if $val.Description}}{{$val.Description}}{{else}}{{$key}}{{end}}</label>
 | 
			
		||||
								{{if eq $val.Type "number"}}
 | 
			
		||||
									<input {{if $val.Required}}required{{end}} type="number" name="inputs[{{$key}}]" {{if $val.Default}}value="{{$val.Default}}"{{end}}>
 | 
			
		||||
								{{else if eq $val.Type "string"}}
 | 
			
		||||
									<input {{if $val.Required}}required{{end}} type="text" name="inputs[{{$key}}]" {{if $val.Default}}value="{{$val.Default}}"{{end}}>
 | 
			
		||||
								{{else if eq $val.Type "choice"}}
 | 
			
		||||
									<div class="ui selection dropdown">
 | 
			
		||||
										<input name="inputs[{{$key}}]" type="hidden" value="{{$val.Default}}">
 | 
			
		||||
										{{svg "octicon-triangle-down" 14 "dropdown icon"}}
 | 
			
		||||
										<div class="text"></div>
 | 
			
		||||
										<div class="menu">
 | 
			
		||||
											{{range $opt := $val.Options}}
 | 
			
		||||
												<div data-value="{{$opt}}" class="{{if eq $val.Default $opt}}active selected {{end}}item">{{$opt}}</div>
 | 
			
		||||
											{{end}}
 | 
			
		||||
										</div>
 | 
			
		||||
									</div>
 | 
			
		||||
								{{else}}
 | 
			
		||||
									<strong>{{ctx.Locale.Tr "actions.workflow.dispatch.invalid_input_type" $val.Type}}</strong>
 | 
			
		||||
								{{end}}
 | 
			
		||||
							{{end}}
 | 
			
		||||
						</div>
 | 
			
		||||
					{{end}}
 | 
			
		||||
 | 
			
		||||
					{{if .WarnDispatchInputsLimit}}
 | 
			
		||||
						<div class="text yellow tw-mb-4">
 | 
			
		||||
							{{svg "octicon-alert"}} {{ctx.Locale.Tr "actions.workflow.dispatch.warn_input_limit" .DispatchInputsLimit}}
 | 
			
		||||
						</div>
 | 
			
		||||
					{{end}}
 | 
			
		||||
 | 
			
		||||
					{{.CsrfTokenHtml}}
 | 
			
		||||
					<input type="hidden" name="ref" value="{{if $.Repo.BranchName}}{{$.Repo.BranchName}}{{else}}{{$.Repo.Repository.DefaultBranch}}{{end}}">
 | 
			
		||||
					<input type="hidden" name="workflow" value="{{$.CurWorkflow}}">
 | 
			
		||||
					<input type="hidden" name="actor" value="{{$.CurActor}}">
 | 
			
		||||
					<input type="hidden" name="status" value="{{$.CurStatus}}">
 | 
			
		||||
					<button type="submit" id="workflow-dispatch-submit" class="ui primary small compact button">{{ctx.Locale.Tr "actions.workflow.dispatch.run"}}</button>
 | 
			
		||||
				</form>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
	<script>
 | 
			
		||||
		window.addEventListener('load', () => {
 | 
			
		||||
			const dropdown = $('#workflow_dispatch_dropdown');
 | 
			
		||||
			const menu = dropdown.find('> .menu');
 | 
			
		||||
			$(document.body).on('click', (ev) => {
 | 
			
		||||
				if (!dropdown[0].contains(ev.target) && menu.hasClass('visible')) {
 | 
			
		||||
					menu.transition({ animation: 'slide down out', duration: 200, queue: false });
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
			dropdown.on('click', (ev) => {
 | 
			
		||||
				const inMenu = $(ev.target).closest(menu).length !== 0;
 | 
			
		||||
				if (inMenu) return;
 | 
			
		||||
				ev.stopPropagation();
 | 
			
		||||
				if (menu.hasClass('visible')) {
 | 
			
		||||
					menu.transition({ animation: 'slide down out', duration: 200, queue: false });
 | 
			
		||||
				} else {
 | 
			
		||||
					menu.transition({ animation: 'slide down in', duration: 200, queue: true });
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
		});
 | 
			
		||||
	</script>
 | 
			
		||||
</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -76,6 +76,11 @@
 | 
			
		|||
						</button>
 | 
			
		||||
					{{end}}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{{if $.CurWorkflowDispatch}}
 | 
			
		||||
					{{template "repo/actions/dispatch" .}}
 | 
			
		||||
				{{end}}
 | 
			
		||||
 | 
			
		||||
				{{template "repo/actions/runs_list" .}}
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										75
									
								
								templates/swagger/v1_json.tmpl
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										75
									
								
								templates/swagger/v1_json.tmpl
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -4239,6 +4239,56 @@
 | 
			
		|||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/repos/{owner}/{repo}/actions/workflows/{workflowname}/dispatches": {
 | 
			
		||||
      "post": {
 | 
			
		||||
        "consumes": [
 | 
			
		||||
          "application/json"
 | 
			
		||||
        ],
 | 
			
		||||
        "tags": [
 | 
			
		||||
          "repository"
 | 
			
		||||
        ],
 | 
			
		||||
        "summary": "Dispatches a workflow",
 | 
			
		||||
        "operationId": "DispatchWorkflow",
 | 
			
		||||
        "parameters": [
 | 
			
		||||
          {
 | 
			
		||||
            "type": "string",
 | 
			
		||||
            "description": "owner of the repo",
 | 
			
		||||
            "name": "owner",
 | 
			
		||||
            "in": "path",
 | 
			
		||||
            "required": true
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "type": "string",
 | 
			
		||||
            "description": "name of the repo",
 | 
			
		||||
            "name": "repo",
 | 
			
		||||
            "in": "path",
 | 
			
		||||
            "required": true
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "type": "string",
 | 
			
		||||
            "description": "name of the workflow",
 | 
			
		||||
            "name": "workflowname",
 | 
			
		||||
            "in": "path",
 | 
			
		||||
            "required": true
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "name": "body",
 | 
			
		||||
            "in": "body",
 | 
			
		||||
            "schema": {
 | 
			
		||||
              "$ref": "#/definitions/DispatchWorkflowOption"
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "responses": {
 | 
			
		||||
          "204": {
 | 
			
		||||
            "$ref": "#/responses/empty"
 | 
			
		||||
          },
 | 
			
		||||
          "404": {
 | 
			
		||||
            "$ref": "#/responses/notFound"
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/repos/{owner}/{repo}/activities/feeds": {
 | 
			
		||||
      "get": {
 | 
			
		||||
        "produces": [
 | 
			
		||||
| 
						 | 
				
			
			@ -20902,6 +20952,29 @@
 | 
			
		|||
      },
 | 
			
		||||
      "x-go-package": "code.gitea.io/gitea/modules/structs"
 | 
			
		||||
    },
 | 
			
		||||
    "DispatchWorkflowOption": {
 | 
			
		||||
      "description": "DispatchWorkflowOption options when dispatching a workflow",
 | 
			
		||||
      "type": "object",
 | 
			
		||||
      "required": [
 | 
			
		||||
        "ref"
 | 
			
		||||
      ],
 | 
			
		||||
      "properties": {
 | 
			
		||||
        "inputs": {
 | 
			
		||||
          "description": "Input keys and values configured in the workflow file.",
 | 
			
		||||
          "type": "object",
 | 
			
		||||
          "additionalProperties": {
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "x-go-name": "Inputs"
 | 
			
		||||
        },
 | 
			
		||||
        "ref": {
 | 
			
		||||
          "description": "Git reference for the workflow",
 | 
			
		||||
          "type": "string",
 | 
			
		||||
          "x-go-name": "Ref"
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "x-go-package": "code.gitea.io/gitea/modules/structs"
 | 
			
		||||
    },
 | 
			
		||||
    "EditAttachmentOptions": {
 | 
			
		||||
      "description": "EditAttachmentOptions options for editing attachments",
 | 
			
		||||
      "type": "object",
 | 
			
		||||
| 
						 | 
				
			
			@ -26627,7 +26700,7 @@
 | 
			
		|||
    "parameterBodies": {
 | 
			
		||||
      "description": "parameterBodies",
 | 
			
		||||
      "schema": {
 | 
			
		||||
        "$ref": "#/definitions/UpdateVariableOption"
 | 
			
		||||
        "$ref": "#/definitions/DispatchWorkflowOption"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "redirect": {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										74
									
								
								tests/e2e/actions.test.e2e.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								tests/e2e/actions.test.e2e.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,74 @@
 | 
			
		|||
// @ts-check
 | 
			
		||||
import {test, expect} from '@playwright/test';
 | 
			
		||||
import {login_user, load_logged_in_context} from './utils_e2e.js';
 | 
			
		||||
 | 
			
		||||
test.beforeAll(async ({browser}, workerInfo) => {
 | 
			
		||||
  await login_user(browser, workerInfo, 'user2');
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('Test workflow dispatch present', async ({browser}, workerInfo) => {
 | 
			
		||||
  const context = await load_logged_in_context(browser, workerInfo, 'user2');
 | 
			
		||||
  /** @type {import('@playwright/test').Page} */
 | 
			
		||||
  const page = await context.newPage();
 | 
			
		||||
 | 
			
		||||
  await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
 | 
			
		||||
 | 
			
		||||
  await expect(page.getByText('This workflow has a workflow_dispatch event trigger.')).toBeVisible();
 | 
			
		||||
 | 
			
		||||
  const run_workflow_btn = page.locator('#workflow_dispatch_dropdown>button');
 | 
			
		||||
  await expect(run_workflow_btn).toBeVisible();
 | 
			
		||||
 | 
			
		||||
  const menu = page.locator('#workflow_dispatch_dropdown>.menu');
 | 
			
		||||
  await expect(menu).toBeHidden();
 | 
			
		||||
  await run_workflow_btn.click();
 | 
			
		||||
  await expect(menu).toBeVisible();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('Test workflow dispatch error: missing inputs', async ({browser}, workerInfo) => {
 | 
			
		||||
  test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
 | 
			
		||||
 | 
			
		||||
  const context = await load_logged_in_context(browser, workerInfo, 'user2');
 | 
			
		||||
  /** @type {import('@playwright/test').Page} */
 | 
			
		||||
  const page = await context.newPage();
 | 
			
		||||
 | 
			
		||||
  await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
 | 
			
		||||
  await page.waitForLoadState('networkidle');
 | 
			
		||||
 | 
			
		||||
  await page.locator('#workflow_dispatch_dropdown>button').click();
 | 
			
		||||
 | 
			
		||||
  await page.waitForTimeout(1000);
 | 
			
		||||
 | 
			
		||||
  // Remove the required attribute so we can trigger the error message!
 | 
			
		||||
  await page.evaluate(() => {
 | 
			
		||||
    // eslint-disable-next-line no-undef
 | 
			
		||||
    const elem = document.querySelector('input[name="inputs[string2]"]');
 | 
			
		||||
    elem?.removeAttribute('required');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await page.locator('#workflow-dispatch-submit').click();
 | 
			
		||||
  await page.waitForLoadState('networkidle');
 | 
			
		||||
 | 
			
		||||
  await expect(page.getByText('Require value for input "String w/o. default".')).toBeVisible();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('Test workflow dispatch success', async ({browser}, workerInfo) => {
 | 
			
		||||
  test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
 | 
			
		||||
 | 
			
		||||
  const context = await load_logged_in_context(browser, workerInfo, 'user2');
 | 
			
		||||
  /** @type {import('@playwright/test').Page} */
 | 
			
		||||
  const page = await context.newPage();
 | 
			
		||||
 | 
			
		||||
  await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
 | 
			
		||||
  await page.waitForLoadState('networkidle');
 | 
			
		||||
 | 
			
		||||
  await page.locator('#workflow_dispatch_dropdown>button').click();
 | 
			
		||||
  await page.waitForTimeout(1000);
 | 
			
		||||
 | 
			
		||||
  await page.type('input[name="inputs[string2]"]', 'abc');
 | 
			
		||||
  await page.locator('#workflow-dispatch-submit').click();
 | 
			
		||||
  await page.waitForLoadState('networkidle');
 | 
			
		||||
 | 
			
		||||
  await expect(page.getByText('Workflow run was successfully requested.')).toBeVisible();
 | 
			
		||||
 | 
			
		||||
  await expect(page.locator('.run-list>:first-child .run-list-meta', {hasText: 'now'})).toBeVisible();
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
ref: refs/heads/main
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
[core]
 | 
			
		||||
	repositoryformatversion = 0
 | 
			
		||||
	filemode = true
 | 
			
		||||
	bare = true
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
Unnamed repository; edit this file 'description' to name the repository.
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
# git ls-files --others --exclude-from=.git/info/exclude
 | 
			
		||||
# Lines that start with '#' are comments.
 | 
			
		||||
# For a project mostly in C, the following would be a good set of
 | 
			
		||||
# exclude patterns (uncomment them if you want to use them):
 | 
			
		||||
# *.[oa]
 | 
			
		||||
# *~
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
# pack-refs with: peeled fully-peeled sorted 
 | 
			
		||||
774f93df12d14931ea93259ae93418da4482fcc1 refs/heads/main
 | 
			
		||||
774f93df12d14931ea93259ae93418da4482fcc1 refs/heads/master
 | 
			
		||||
| 
						 | 
				
			
			@ -396,3 +396,46 @@ func TestCreateDeleteRefEvent(t *testing.T) {
 | 
			
		|||
		assert.NotNil(t, run)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWorkflowDispatchEvent(t *testing.T) {
 | 
			
		||||
	onGiteaRun(t, func(t *testing.T, u *url.URL) {
 | 
			
		||||
		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
 | 
			
		||||
 | 
			
		||||
		// create the repo
 | 
			
		||||
		repo, sha, f := CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch",
 | 
			
		||||
			[]unit_model.Type{unit_model.TypeActions}, nil,
 | 
			
		||||
			[]*files_service.ChangeRepoFile{
 | 
			
		||||
				{
 | 
			
		||||
					Operation: "create",
 | 
			
		||||
					TreePath:  ".gitea/workflows/dispatch.yml",
 | 
			
		||||
					ContentReader: strings.NewReader(
 | 
			
		||||
						"name: test\n" +
 | 
			
		||||
							"on: [workflow_dispatch]\n" +
 | 
			
		||||
							"jobs:\n" +
 | 
			
		||||
							"  test:\n" +
 | 
			
		||||
							"    runs-on: ubuntu-latest\n" +
 | 
			
		||||
							"    steps:\n" +
 | 
			
		||||
							"      - run: echo helloworld\n",
 | 
			
		||||
					),
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		)
 | 
			
		||||
		defer f()
 | 
			
		||||
 | 
			
		||||
		gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
 | 
			
		||||
		assert.NoError(t, err)
 | 
			
		||||
		defer gitRepo.Close()
 | 
			
		||||
 | 
			
		||||
		workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, sha, "dispatch.yml")
 | 
			
		||||
		assert.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
		inputGetter := func(key string) string {
 | 
			
		||||
			return ""
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
 | 
			
		||||
		assert.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
		assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -95,9 +95,9 @@ func TestAPISearchRepo(t *testing.T) {
 | 
			
		|||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			name: "RepositoriesMax50", requestURL: "/api/v1/repos/search?limit=50&private=false", expectedResults: expectedResults{
 | 
			
		||||
				nil:   {count: 36},
 | 
			
		||||
				user:  {count: 36},
 | 
			
		||||
				user2: {count: 36},
 | 
			
		||||
				nil:   {count: 37},
 | 
			
		||||
				user:  {count: 37},
 | 
			
		||||
				user2: {count: 37},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -81,3 +81,16 @@
 | 
			
		|||
    max-width: 110px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#workflow_dispatch_dropdown {
 | 
			
		||||
  min-width: min-content;
 | 
			
		||||
}
 | 
			
		||||
#workflow_dispatch_dropdown > button {
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
}
 | 
			
		||||
@media (max-width: 640px) or (767.98px < width < 854px) {
 | 
			
		||||
  #workflow_dispatch_dropdown .menu {
 | 
			
		||||
    left: auto;
 | 
			
		||||
    right: 0;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue