From 51735c415bb29e40b1b02c14bb4f9854e8e27b24 Mon Sep 17 00:00:00 2001 From: Mai-Lapyst Date: Fri, 28 Jun 2024 05:17:11 +0000 Subject: [PATCH] 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///actions/workflows//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) ![image](/attachments/2db50c9e-898f-41cb-b698-43edeefd2573) ## 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!
Example workflow ```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" ```
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/3334 Reviewed-by: Earl Warren Co-authored-by: Mai-Lapyst Co-committed-by: Mai-Lapyst --- custom/conf/app.example.ini | 2 + models/fixtures/repo_unit.yml | 38 ++++ models/fixtures/repository.yml | 30 +++ models/fixtures/user.yml | 2 +- models/repo/repo_list_test.go | 10 +- modules/actions/github.go | 8 + modules/actions/workflows.go | 1 + modules/actions/workflows_test.go | 7 + modules/setting/actions.go | 2 + modules/structs/hook.go | 8 + modules/structs/workflow.go | 15 ++ modules/webhook/type.go | 1 + options/locale/locale_en-US.ini | 7 + release-notes/8.0.0/3334.md | 1 + routers/api/v1/api.go | 6 + routers/api/v1/repo/action.go | 70 +++++++ routers/api/v1/swagger/options.go | 3 + routers/web/repo/actions/actions.go | 33 +++- routers/web/repo/actions/manual.go | 62 +++++++ routers/web/web.go | 1 + services/actions/workflows.go | 171 ++++++++++++++++++ templates/repo/actions/dispatch.tmpl | 99 ++++++++++ templates/repo/actions/list.tmpl | 5 + templates/swagger/v1_json.tmpl | 75 +++++++- tests/e2e/actions.test.e2e.js | 74 ++++++++ .../user2/test_workflows.git/HEAD | 1 + .../user2/test_workflows.git/config | 4 + .../user2/test_workflows.git/description | 1 + .../user2/test_workflows.git/info/exclude | 6 + .../26/c8f930a36802d9cfb9785ca88704b1f52347aa | Bin 0 -> 51 bytes .../2d/7f57e0a452699a5d2da0e42dcb2375de546c0a | Bin 0 -> 62 bytes .../2d/89b2afa3e19e924330b4307a181714a4179010 | Bin 0 -> 423 bytes .../4b/825dc642cb6eb9a060e54bf8d69288fbee4904 | Bin 0 -> 15 bytes .../77/4f93df12d14931ea93259ae93418da4482fcc1 | Bin 0 -> 333 bytes .../96/63cd4783a54f3e57b2dd908b077cf8126c826c | Bin 0 -> 50 bytes .../user2/test_workflows.git/packed-refs | 3 + tests/integration/actions_trigger_test.go | 43 +++++ tests/integration/api_repo_test.go | 6 +- web_src/css/actions.css | 13 ++ 39 files changed, 792 insertions(+), 16 deletions(-) create mode 100644 modules/structs/workflow.go create mode 100644 release-notes/8.0.0/3334.md create mode 100644 routers/web/repo/actions/manual.go create mode 100644 services/actions/workflows.go create mode 100644 templates/repo/actions/dispatch.tmpl create mode 100644 tests/e2e/actions.test.e2e.js create mode 100644 tests/gitea-repositories-meta/user2/test_workflows.git/HEAD create mode 100644 tests/gitea-repositories-meta/user2/test_workflows.git/config create mode 100644 tests/gitea-repositories-meta/user2/test_workflows.git/description create mode 100644 tests/gitea-repositories-meta/user2/test_workflows.git/info/exclude create mode 100644 tests/gitea-repositories-meta/user2/test_workflows.git/objects/26/c8f930a36802d9cfb9785ca88704b1f52347aa create mode 100644 tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/7f57e0a452699a5d2da0e42dcb2375de546c0a create mode 100644 tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/89b2afa3e19e924330b4307a181714a4179010 create mode 100644 tests/gitea-repositories-meta/user2/test_workflows.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 create mode 100644 tests/gitea-repositories-meta/user2/test_workflows.git/objects/77/4f93df12d14931ea93259ae93418da4482fcc1 create mode 100644 tests/gitea-repositories-meta/user2/test_workflows.git/objects/96/63cd4783a54f3e57b2dd908b077cf8126c826c create mode 100644 tests/gitea-repositories-meta/user2/test_workflows.git/packed-refs diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index cc029e9970..cdb7629887 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index e4fc5d9d00..6dac78f588 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -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 diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index 9d08c7bb0a..845dae7fc1 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -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 \ No newline at end of file diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index 07df059dc5..8e216fbc7d 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -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 diff --git a/models/repo/repo_list_test.go b/models/repo/repo_list_test.go index ca6007f6c7..6b1bb39b85 100644 --- a/models/repo/repo_list_test.go +++ b/models/repo/repo_list_test.go @@ -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", diff --git a/modules/actions/github.go b/modules/actions/github.go index 749bcd7c89..c27d4edf53 100644 --- a/modules/actions/github.go +++ b/modules/actions/github.go @@ -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, diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index 8677e1fd55..9319c05119 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -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, diff --git a/modules/actions/workflows_test.go b/modules/actions/workflows_test.go index c8e1e553fe..dca0c2924c 100644 --- a/modules/actions/workflows_test.go +++ b/modules/actions/workflows_test.go @@ -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 { diff --git a/modules/setting/actions.go b/modules/setting/actions.go index e9b735dae8..804ed9ec72 100644 --- a/modules/setting/actions.go +++ b/modules/setting/actions.go @@ -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, } ) diff --git a/modules/structs/hook.go b/modules/structs/hook.go index 784e69ea84..bb40aa06c0 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -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"` diff --git a/modules/structs/workflow.go b/modules/structs/workflow.go new file mode 100644 index 0000000000..c4429ea0a2 --- /dev/null +++ b/modules/structs/workflow.go @@ -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"` +} diff --git a/modules/webhook/type.go b/modules/webhook/type.go index 865f30c926..244dc423c1 100644 --- a/modules/webhook/type.go +++ b/modules/webhook/type.go @@ -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 diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 03a7edc238..a6a956a479 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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 workflow_dispatch 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. diff --git a/release-notes/8.0.0/3334.md b/release-notes/8.0.0/3334.md new file mode 100644 index 0000000000..3e2b22e97e --- /dev/null +++ b/release-notes/8.0.0/3334.md @@ -0,0 +1 @@ +Added support for the `workflow_dispatch` workflow trigger diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 93798d1dda..7757f401a7 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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). diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index f6656d89c6..3e25327ccb 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -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) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index b0a5158a42..c34470030b 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -216,4 +216,7 @@ type swaggerParameterBodies struct { // in:body UpdateVariableOption api.UpdateVariableOption + + // in:body + DispatchWorkflowOption api.DispatchWorkflowOption } diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index a0f03ec7e9..90d3226f2d 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -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="//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 diff --git a/routers/web/repo/actions/manual.go b/routers/web/repo/actions/manual.go new file mode 100644 index 0000000000..86a6014761 --- /dev/null +++ b/routers/web/repo/actions/manual.go @@ -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) +} diff --git a/routers/web/web.go b/routers/web/web.go index 3781ffc058..3480a18844 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/actions/workflows.go b/services/actions/workflows.go new file mode 100644 index 0000000000..deff7e7dd6 --- /dev/null +++ b/services/actions/workflows.go @@ -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 +} diff --git a/templates/repo/actions/dispatch.tmpl b/templates/repo/actions/dispatch.tmpl new file mode 100644 index 0000000000..520a9b50c2 --- /dev/null +++ b/templates/repo/actions/dispatch.tmpl @@ -0,0 +1,99 @@ +
+ + {{ctx.Locale.Tr "actions.workflow.dispatch.trigger_found"}} + + + +
\ No newline at end of file diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index b66d0e360a..263530f9a7 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -76,6 +76,11 @@ {{end}} + + {{if $.CurWorkflowDispatch}} + {{template "repo/actions/dispatch" .}} + {{end}} + {{template "repo/actions/runs_list" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index f11e67d4ad..dacec3ed1a 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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": { diff --git a/tests/e2e/actions.test.e2e.js b/tests/e2e/actions.test.e2e.js new file mode 100644 index 0000000000..d7ca75bfc6 --- /dev/null +++ b/tests/e2e/actions.test.e2e.js @@ -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(); +}); diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/HEAD b/tests/gitea-repositories-meta/user2/test_workflows.git/HEAD new file mode 100644 index 0000000000..b870d82622 --- /dev/null +++ b/tests/gitea-repositories-meta/user2/test_workflows.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/config b/tests/gitea-repositories-meta/user2/test_workflows.git/config new file mode 100644 index 0000000000..07d359d07c --- /dev/null +++ b/tests/gitea-repositories-meta/user2/test_workflows.git/config @@ -0,0 +1,4 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/description b/tests/gitea-repositories-meta/user2/test_workflows.git/description new file mode 100644 index 0000000000..498b267a8c --- /dev/null +++ b/tests/gitea-repositories-meta/user2/test_workflows.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/info/exclude b/tests/gitea-repositories-meta/user2/test_workflows.git/info/exclude new file mode 100644 index 0000000000..a5196d1be8 --- /dev/null +++ b/tests/gitea-repositories-meta/user2/test_workflows.git/info/exclude @@ -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] +# *~ diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/objects/26/c8f930a36802d9cfb9785ca88704b1f52347aa b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/26/c8f930a36802d9cfb9785ca88704b1f52347aa new file mode 100644 index 0000000000000000000000000000000000000000..439b74accc49df56c15ad478d76a17e6d8c3fdd5 GIT binary patch literal 51 zcmV-30L=e*0V^p=O;s>9V=yrQ0)_JYqU^Ms{PJQ3-TLqcOM)_I#p*72qI+7o^j=5~ J7XV4r5MDNv7LWh{ literal 0 HcmV?d00001 diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/7f57e0a452699a5d2da0e42dcb2375de546c0a b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/7f57e0a452699a5d2da0e42dcb2375de546c0a new file mode 100644 index 0000000000000000000000000000000000000000..ac621857bd9e010baaef896cc06f0cdec67a7472 GIT binary patch literal 62 zcmV-E0Kxxw0V^p=O;s>4WiT`_Ff%bxC`m0Y(M`!LE=Vj%&d{sO&0)~(+_Zl2!+DdO U4Yn9mNr;Oq5uYFc0M`-{fWkl+F8}}l literal 0 HcmV?d00001 diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/89b2afa3e19e924330b4307a181714a4179010 b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/89b2afa3e19e924330b4307a181714a4179010 new file mode 100644 index 0000000000000000000000000000000000000000..156f4eedffa078713f011e951fcb47de69da8a16 GIT binary patch literal 423 zcmV;Y0a*Tc0i{$;Ps1<_<-A{EA*8`!upv%6f)E3tabQT?rha%`ScprKQX#~DCtq#4 zX;YE7)#TjG|E!#(FFCa9sgWVP4r2kJzzx$4qE?89WDauLHltU`!4hHpkV<=uh z>u@+3kK?g#hc6#4zz3Io92c~QWluS!r5Vg#>CX<*gF zCUclaToaO0IFr~X+F}eOTz$|?~n%WUg78XDbqk#^ zpRB`X>?nL8cU?a8TUEEXDaUDCCpf}wHm%$26>>5s3!E(SJY-m&WkLLCr})1)S(9~n z1}ERp4AXMq*4)Am%XE?F?~R8KAb|PGH;{I)k;Vqr&f6NEfD6YKuSFS{Ry8{x?O4Z_ z$RJ-omP21;$kz9ZUmrkb+3nG8k?A*8;VV8`OT=HMnZ9xt%bOqc#3igSx^D>c=J|aYIF4ud$F-Yonv-&tM~z`FJUYw)oSO~PfS)&pyZGx1 fKuH3Jx-HVO9w5aXog{hJgfu=)M^b_>LGF+nvv8gS literal 0 HcmV?d00001 diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/objects/96/63cd4783a54f3e57b2dd908b077cf8126c826c b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/96/63cd4783a54f3e57b2dd908b077cf8126c826c new file mode 100644 index 0000000000000000000000000000000000000000..c07ce1e66010499aa6dc06c4f40ac1f798424947 GIT binary patch literal 50 zcmV-20L}k+0V^p=O;s>9WiT-S0tLOa{G#;Ktb7Kw6F&_WXE5D7zq2A{MLWyJugdPL I06Aq4t button { + white-space: nowrap; +} +@media (max-width: 640px) or (767.98px < width < 854px) { + #workflow_dispatch_dropdown .menu { + left: auto; + right: 0; + } +}