loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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)

![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!

<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>

authored by

Mai-Lapyst
Mai-Lapyst
and committed by
Earl Warren
51735c41 544cbc6f

+792 -16
+2
custom/conf/app.example.ini
··· 2714 2714 ;ABANDONED_JOB_TIMEOUT = 24h 2715 2715 ;; Strings committers can place inside a commit message or PR title to skip executing the corresponding actions workflow 2716 2716 ;SKIP_WORKFLOW_STRINGS = [skip ci],[ci skip],[no ci],[skip actions],[actions skip] 2717 + ;; Limit on inputs for manual / workflow_dispatch triggers, default is 10 2718 + ;LIMIT_DISPATCH_INPUTS = 10 2717 2719 2718 2720 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 2719 2721 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+38
models/fixtures/repo_unit.yml
··· 750 750 type: 3 751 751 config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}" 752 752 created_unix: 946684810 753 + 754 + - 755 + id: 108 756 + repo_id: 62 757 + type: 1 758 + config: "{}" 759 + created_unix: 946684810 760 + 761 + - 762 + id: 109 763 + repo_id: 62 764 + type: 2 765 + created_unix: 946684810 766 + 767 + - 768 + id: 110 769 + repo_id: 62 770 + type: 3 771 + created_unix: 946684810 772 + 773 + - 774 + id: 111 775 + repo_id: 62 776 + type: 4 777 + created_unix: 946684810 778 + 779 + - 780 + id: 112 781 + repo_id: 62 782 + type: 5 783 + created_unix: 946684810 784 + 785 + - 786 + id: 113 787 + repo_id: 62 788 + type: 10 789 + config: "{}" 790 + created_unix: 946684810
+30
models/fixtures/repository.yml
··· 1782 1782 size: 0 1783 1783 is_fsck_enabled: true 1784 1784 close_issues_via_commit_in_any_branch: false 1785 + 1786 + - id: 62 1787 + owner_id: 2 1788 + owner_name: user2 1789 + lower_name: test_workflows 1790 + name: test_workflows 1791 + default_branch: main 1792 + num_watches: 0 1793 + num_stars: 0 1794 + num_forks: 0 1795 + num_issues: 0 1796 + num_closed_issues: 0 1797 + num_pulls: 0 1798 + num_closed_pulls: 0 1799 + num_milestones: 0 1800 + num_closed_milestones: 0 1801 + num_projects: 0 1802 + num_closed_projects: 0 1803 + is_private: false 1804 + is_empty: false 1805 + is_archived: false 1806 + is_mirror: false 1807 + status: 0 1808 + is_fork: false 1809 + fork_id: 0 1810 + is_template: false 1811 + template_id: 0 1812 + size: 0 1813 + is_fsck_enabled: true 1814 + close_issues_via_commit_in_any_branch: false
+1 -1
models/fixtures/user.yml
··· 66 66 num_followers: 2 67 67 num_following: 1 68 68 num_stars: 2 69 - num_repos: 16 69 + num_repos: 17 70 70 num_teams: 0 71 71 num_members: 0 72 72 visibility: 0
+5 -5
models/repo/repo_list_test.go
··· 138 138 { 139 139 name: "AllPublic/PublicRepositoriesOfUserIncludingCollaborative", 140 140 opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, AllPublic: true, Template: optional.Some(false)}, 141 - count: 34, 141 + count: 35, 142 142 }, 143 143 { 144 144 name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborative", 145 145 opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true, AllLimited: true, Template: optional.Some(false)}, 146 - count: 39, 146 + count: 40, 147 147 }, 148 148 { 149 149 name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborativeByName", 150 150 opts: &repo_model.SearchRepoOptions{Keyword: "test", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true}, 151 - count: 15, 151 + count: 16, 152 152 }, 153 153 { 154 154 name: "AllPublic/PublicAndPrivateRepositoriesOfUser2IncludingCollaborativeByName", 155 155 opts: &repo_model.SearchRepoOptions{Keyword: "test", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18, Private: true, AllPublic: true}, 156 - count: 13, 156 + count: 14, 157 157 }, 158 158 { 159 159 name: "AllPublic/PublicRepositoriesOfOrganization", 160 160 opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, AllPublic: true, Collaborate: optional.Some(false), Template: optional.Some(false)}, 161 - count: 34, 161 + count: 35, 162 162 }, 163 163 { 164 164 name: "AllTemplates",
+8
modules/actions/github.go
··· 23 23 GithubEventPullRequestComment = "pull_request_comment" 24 24 GithubEventGollum = "gollum" 25 25 GithubEventSchedule = "schedule" 26 + GithubEventWorkflowDispatch = "workflow_dispatch" 26 27 ) 27 28 28 29 // IsDefaultBranchWorkflow returns true if the event only triggers workflows on the default branch ··· 52 53 // GitHub "schedule" event 53 54 // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule 54 55 return true 56 + case webhook_module.HookEventWorkflowDispatch: 57 + // GitHub "workflow_dispatch" event 58 + // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch 59 + return true 55 60 case webhook_module.HookEventIssues, 56 61 webhook_module.HookEventIssueAssign, 57 62 webhook_module.HookEventIssueLabel, ··· 73 78 // See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#gollum 74 79 case GithubEventGollum: 75 80 return triggedEvent == webhook_module.HookEventWiki 81 + 82 + case GithubEventWorkflowDispatch: 83 + return triggedEvent == webhook_module.HookEventWorkflowDispatch 76 84 77 85 case GithubEventIssues: 78 86 switch triggedEvent {
+1
modules/actions/workflows.go
··· 191 191 192 192 switch triggedEvent { 193 193 case // events with no activity types 194 + webhook_module.HookEventWorkflowDispatch, 194 195 webhook_module.HookEventCreate, 195 196 webhook_module.HookEventDelete, 196 197 webhook_module.HookEventFork,
+7
modules/actions/workflows_test.go
··· 125 125 yamlOn: "on: schedule", 126 126 expected: true, 127 127 }, 128 + { 129 + desc: "HookEventWorkflowDispatch(workflow_dispatch) matches GithubEventWorkflowDispatch(workflow_dispatch)", 130 + triggedEvent: webhook_module.HookEventWorkflowDispatch, 131 + payload: nil, 132 + yamlOn: "on: workflow_dispatch", 133 + expected: true, 134 + }, 128 135 } 129 136 130 137 for _, tc := range testCases {
+2
modules/setting/actions.go
··· 21 21 EndlessTaskTimeout time.Duration `ini:"ENDLESS_TASK_TIMEOUT"` 22 22 AbandonedJobTimeout time.Duration `ini:"ABANDONED_JOB_TIMEOUT"` 23 23 SkipWorkflowStrings []string `ìni:"SKIP_WORKFLOW_STRINGS"` 24 + LimitDispatchInputs int64 `ini:"LIMIT_DISPATCH_INPUTS"` 24 25 }{ 25 26 Enabled: true, 26 27 DefaultActionsURL: defaultActionsURLForgejo, 27 28 SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"}, 29 + LimitDispatchInputs: 10, 28 30 } 29 31 ) 30 32
+8
modules/structs/hook.go
··· 416 416 Action HookScheduleAction `json:"action"` 417 417 } 418 418 419 + type WorkflowDispatchPayload struct { 420 + Inputs map[string]string `json:"inputs"` 421 + Ref string `json:"ref"` 422 + Repository *Repository `json:"repository"` 423 + Sender *User `json:"sender"` 424 + Workflow string `json:"workflow"` 425 + } 426 + 419 427 // ReviewPayload FIXME 420 428 type ReviewPayload struct { 421 429 Type string `json:"type"`
+15
modules/structs/workflow.go
··· 1 + // Copyright The Forgejo Authors. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package structs 5 + 6 + // DispatchWorkflowOption options when dispatching a workflow 7 + // swagger:model 8 + type DispatchWorkflowOption struct { 9 + // Git reference for the workflow 10 + // 11 + // required: true 12 + Ref string `json:"ref"` 13 + // Input keys and values configured in the workflow file. 14 + Inputs map[string]string `json:"inputs"` 15 + }
+1
modules/webhook/type.go
··· 32 32 HookEventRelease HookEventType = "release" 33 33 HookEventPackage HookEventType = "package" 34 34 HookEventSchedule HookEventType = "schedule" 35 + HookEventWorkflowDispatch HookEventType = "workflow_dispatch" 35 36 ) 36 37 37 38 // Event returns the HookEventType as an event string
+7
options/locale/locale_en-US.ini
··· 3769 3769 workflow.enable = Enable workflow 3770 3770 workflow.enable_success = Workflow "%s" enabled successfully. 3771 3771 workflow.disabled = Workflow is disabled. 3772 + workflow.dispatch.trigger_found = This workflow has a <c>workflow_dispatch</c> event trigger. 3773 + workflow.dispatch.use_from = Use workflow from 3774 + workflow.dispatch.run = Run workflow 3775 + workflow.dispatch.success = Workflow run was successfully requested. 3776 + workflow.dispatch.input_required = Require value for input "%s". 3777 + workflow.dispatch.invalid_input_type = Invalid input type "%s". 3778 + workflow.dispatch.warn_input_limit = Only displaying the first %d inputs. 3772 3779 3773 3780 need_approval_desc = Need approval to run workflows for fork pull request. 3774 3781
+1
release-notes/8.0.0/3334.md
··· 1 + Added support for the `workflow_dispatch` workflow trigger
+6
routers/api/v1/api.go
··· 1123 1123 }, reqToken(), reqAdmin()) 1124 1124 m.Group("/actions", func() { 1125 1125 m.Get("/tasks", repo.ListActionTasks) 1126 + 1127 + m.Group("/workflows", func() { 1128 + m.Group("/{workflowname}", func() { 1129 + m.Post("/dispatches", reqToken(), reqRepoWriter(unit.TypeActions), mustNotBeArchived, bind(api.DispatchWorkflowOption{}), repo.DispatchWorkflow) 1130 + }) 1131 + }) 1126 1132 }, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true)) 1127 1133 m.Group("/keys", func() { 1128 1134 m.Combo("").Get(repo.ListDeployKeys).
+70
routers/api/v1/repo/action.go
··· 583 583 584 584 ctx.JSON(http.StatusOK, &res) 585 585 } 586 + 587 + // DispatchWorkflow dispatches a workflow 588 + func DispatchWorkflow(ctx *context.APIContext) { 589 + // swagger:operation POST /repos/{owner}/{repo}/actions/workflows/{workflowname}/dispatches repository DispatchWorkflow 590 + // --- 591 + // summary: Dispatches a workflow 592 + // consumes: 593 + // - application/json 594 + // parameters: 595 + // - name: owner 596 + // in: path 597 + // description: owner of the repo 598 + // type: string 599 + // required: true 600 + // - name: repo 601 + // in: path 602 + // description: name of the repo 603 + // type: string 604 + // required: true 605 + // - name: workflowname 606 + // in: path 607 + // description: name of the workflow 608 + // type: string 609 + // required: true 610 + // - name: body 611 + // in: body 612 + // schema: 613 + // "$ref": "#/definitions/DispatchWorkflowOption" 614 + // responses: 615 + // "204": 616 + // "$ref": "#/responses/empty" 617 + // "404": 618 + // "$ref": "#/responses/notFound" 619 + 620 + opt := web.GetForm(ctx).(*api.DispatchWorkflowOption) 621 + name := ctx.Params("workflowname") 622 + 623 + if len(opt.Ref) == 0 { 624 + ctx.Error(http.StatusBadRequest, "ref", nil) 625 + return 626 + } else if len(name) == 0 { 627 + ctx.Error(http.StatusBadRequest, "workflowname", nil) 628 + return 629 + } 630 + 631 + workflow, err := actions_service.GetWorkflowFromCommit(ctx.Repo.GitRepo, opt.Ref, name) 632 + if err != nil { 633 + if errors.Is(err, util.ErrNotExist) { 634 + ctx.Error(http.StatusNotFound, "GetWorkflowFromCommit", err) 635 + } else { 636 + ctx.Error(http.StatusInternalServerError, "GetWorkflowFromCommit", err) 637 + } 638 + return 639 + } 640 + 641 + inputGetter := func(key string) string { 642 + return opt.Inputs[key] 643 + } 644 + 645 + if err := workflow.Dispatch(ctx, inputGetter, ctx.Repo.Repository, ctx.Doer); err != nil { 646 + if actions_service.IsInputRequiredErr(err) { 647 + ctx.Error(http.StatusBadRequest, "workflow.Dispatch", err) 648 + } else { 649 + ctx.Error(http.StatusInternalServerError, "workflow.Dispatch", err) 650 + } 651 + return 652 + } 653 + 654 + ctx.JSON(http.StatusNoContent, nil) 655 + }
+3
routers/api/v1/swagger/options.go
··· 216 216 217 217 // in:body 218 218 UpdateVariableOption api.UpdateVariableOption 219 + 220 + // in:body 221 + DispatchWorkflowOption api.DispatchWorkflowOption 219 222 }
+27 -6
routers/web/repo/actions/actions.go
··· 7 7 "bytes" 8 8 "fmt" 9 9 "net/http" 10 + "slices" 10 11 "strings" 11 12 12 13 actions_model "code.gitea.io/gitea/models/actions" ··· 18 19 "code.gitea.io/gitea/modules/git" 19 20 "code.gitea.io/gitea/modules/optional" 20 21 "code.gitea.io/gitea/modules/setting" 22 + "code.gitea.io/gitea/modules/util" 21 23 "code.gitea.io/gitea/routers/web/repo" 22 24 "code.gitea.io/gitea/services/context" 23 25 "code.gitea.io/gitea/services/convert" ··· 58 60 func List(ctx *context.Context) { 59 61 ctx.Data["Title"] = ctx.Tr("actions.actions") 60 62 ctx.Data["PageIsActions"] = true 63 + 64 + curWorkflow := ctx.FormString("workflow") 65 + ctx.Data["CurWorkflow"] = curWorkflow 61 66 62 67 var workflows []Workflow 63 68 if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { ··· 140 145 workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job") 141 146 } 142 147 workflows = append(workflows, workflow) 148 + 149 + if workflow.Entry.Name() == curWorkflow { 150 + config := wf.WorkflowDispatchConfig() 151 + if config != nil { 152 + keys := util.KeysOfMap(config.Inputs) 153 + slices.Sort(keys) 154 + if int64(len(config.Inputs)) > setting.Actions.LimitDispatchInputs { 155 + keys = keys[:setting.Actions.LimitDispatchInputs] 156 + } 157 + 158 + ctx.Data["CurWorkflowDispatch"] = config 159 + ctx.Data["CurWorkflowDispatchInputKeys"] = keys 160 + ctx.Data["WarnDispatchInputsLimit"] = int64(len(config.Inputs)) > setting.Actions.LimitDispatchInputs 161 + ctx.Data["DispatchInputsLimit"] = setting.Actions.LimitDispatchInputs 162 + } 163 + } 143 164 } 144 165 } 145 166 ctx.Data["workflows"] = workflows ··· 150 171 page = 1 151 172 } 152 173 153 - workflow := ctx.FormString("workflow") 154 174 actorID := ctx.FormInt64("actor") 155 175 status := ctx.FormInt("status") 156 - ctx.Data["CurWorkflow"] = workflow 157 176 158 177 actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() 159 178 ctx.Data["ActionsConfig"] = actionsConfig 160 179 161 - if len(workflow) > 0 && ctx.Repo.IsAdmin() { 180 + if len(curWorkflow) > 0 && ctx.Repo.IsAdmin() { 162 181 ctx.Data["AllowDisableOrEnableWorkflow"] = true 163 - ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(workflow) 182 + ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(curWorkflow) 164 183 } 165 184 166 185 // if status or actor query param is not given to frontend href, (href="/<repoLink>/actions") ··· 177 196 PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), 178 197 }, 179 198 RepoID: ctx.Repo.Repository.ID, 180 - WorkflowID: workflow, 199 + WorkflowID: curWorkflow, 181 200 TriggerUserID: actorID, 182 201 } 183 202 ··· 203 222 204 223 ctx.Data["Runs"] = runs 205 224 225 + ctx.Data["Repo"] = ctx.Repo 226 + 206 227 actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) 207 228 if err != nil { 208 229 ctx.ServerError("GetActors", err) ··· 214 235 215 236 pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) 216 237 pager.SetDefaultParams(ctx) 217 - pager.AddParamString("workflow", workflow) 238 + pager.AddParamString("workflow", curWorkflow) 218 239 pager.AddParamString("actor", fmt.Sprint(actorID)) 219 240 pager.AddParamString("status", fmt.Sprint(status)) 220 241 ctx.Data["Page"] = pager
+62
routers/web/repo/actions/manual.go
··· 1 + // Copyright The Forgejo Authors. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package actions 5 + 6 + import ( 7 + "net/url" 8 + 9 + actions_service "code.gitea.io/gitea/services/actions" 10 + context_module "code.gitea.io/gitea/services/context" 11 + ) 12 + 13 + func ManualRunWorkflow(ctx *context_module.Context) { 14 + workflowID := ctx.FormString("workflow") 15 + if len(workflowID) == 0 { 16 + ctx.ServerError("workflow", nil) 17 + return 18 + } 19 + 20 + ref := ctx.FormString("ref") 21 + if len(ref) == 0 { 22 + ctx.ServerError("ref", nil) 23 + return 24 + } 25 + 26 + if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { 27 + ctx.ServerError("IsEmpty", err) 28 + return 29 + } else if empty { 30 + ctx.NotFound("IsEmpty", nil) 31 + return 32 + } 33 + 34 + workflow, err := actions_service.GetWorkflowFromCommit(ctx.Repo.GitRepo, ref, workflowID) 35 + if err != nil { 36 + ctx.ServerError("GetWorkflowFromCommit", err) 37 + return 38 + } 39 + 40 + location := ctx.Repo.RepoLink + "/actions?workflow=" + url.QueryEscape(workflowID) + 41 + "&actor=" + url.QueryEscape(ctx.FormString("actor")) + 42 + "&status=" + url.QueryEscape(ctx.FormString("status")) 43 + 44 + formKeyGetter := func(key string) string { 45 + formKey := "inputs[" + key + "]" 46 + return ctx.FormString(formKey) 47 + } 48 + 49 + if err := workflow.Dispatch(ctx, formKeyGetter, ctx.Repo.Repository, ctx.Doer); err != nil { 50 + if actions_service.IsInputRequiredErr(err) { 51 + ctx.Flash.Error(ctx.Locale.Tr("actions.workflow.dispatch.input_required", err.(actions_service.InputRequiredErr).Name)) 52 + ctx.Redirect(location) 53 + return 54 + } 55 + ctx.ServerError("workflow.Dispatch", err) 56 + return 57 + } 58 + 59 + // forward to the page of the run which was just created 60 + ctx.Flash.Info(ctx.Locale.Tr("actions.workflow.dispatch.success")) 61 + ctx.Redirect(location) 62 + }
+1
routers/web/web.go
··· 1376 1376 m.Get("", actions.List) 1377 1377 m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile) 1378 1378 m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) 1379 + m.Post("/manual", reqRepoAdmin, actions.ManualRunWorkflow) 1379 1380 1380 1381 m.Group("/runs", func() { 1381 1382 m.Get("/latest", actions.ViewLatest)
+171
services/actions/workflows.go
··· 1 + // Copyright The Forgejo Authors. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package actions 5 + 6 + import ( 7 + "bytes" 8 + "context" 9 + "errors" 10 + "fmt" 11 + "strconv" 12 + 13 + actions_model "code.gitea.io/gitea/models/actions" 14 + "code.gitea.io/gitea/models/perm" 15 + "code.gitea.io/gitea/models/perm/access" 16 + repo_model "code.gitea.io/gitea/models/repo" 17 + "code.gitea.io/gitea/models/user" 18 + "code.gitea.io/gitea/modules/actions" 19 + "code.gitea.io/gitea/modules/git" 20 + "code.gitea.io/gitea/modules/json" 21 + "code.gitea.io/gitea/modules/setting" 22 + "code.gitea.io/gitea/modules/structs" 23 + "code.gitea.io/gitea/modules/webhook" 24 + "code.gitea.io/gitea/services/convert" 25 + 26 + "github.com/nektos/act/pkg/jobparser" 27 + act_model "github.com/nektos/act/pkg/model" 28 + ) 29 + 30 + type InputRequiredErr struct { 31 + Name string 32 + } 33 + 34 + func (err InputRequiredErr) Error() string { 35 + return fmt.Sprintf("input required for '%s'", err.Name) 36 + } 37 + 38 + func IsInputRequiredErr(err error) bool { 39 + _, ok := err.(InputRequiredErr) 40 + return ok 41 + } 42 + 43 + type Workflow struct { 44 + WorkflowID string 45 + Ref string 46 + Commit *git.Commit 47 + GitEntry *git.TreeEntry 48 + } 49 + 50 + type InputValueGetter func(key string) string 51 + 52 + func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGetter, repo *repo_model.Repository, doer *user.User) error { 53 + content, err := actions.GetContentFromEntry(entry.GitEntry) 54 + if err != nil { 55 + return err 56 + } 57 + 58 + wf, err := act_model.ReadWorkflow(bytes.NewReader(content)) 59 + if err != nil { 60 + return err 61 + } 62 + 63 + fullWorkflowID := ".forgejo/workflows/" + entry.WorkflowID 64 + 65 + title := wf.Name 66 + if len(title) < 1 { 67 + title = fullWorkflowID 68 + } 69 + 70 + inputs := make(map[string]string) 71 + if workflowDispatch := wf.WorkflowDispatchConfig(); workflowDispatch != nil { 72 + for key, input := range workflowDispatch.Inputs { 73 + val := inputGetter(key) 74 + if len(val) == 0 { 75 + val = input.Default 76 + if len(val) == 0 { 77 + if input.Required { 78 + name := input.Description 79 + if len(name) == 0 { 80 + name = key 81 + } 82 + return InputRequiredErr{Name: name} 83 + } 84 + continue 85 + } 86 + } else { 87 + switch input.Type { 88 + case "boolean": 89 + // Since "boolean" inputs are rendered as a checkbox in html, the value inside the form is "on" 90 + val = strconv.FormatBool(val == "on") 91 + } 92 + } 93 + inputs[key] = val 94 + } 95 + } 96 + 97 + if int64(len(inputs)) > setting.Actions.LimitDispatchInputs { 98 + return errors.New("to many inputs") 99 + } 100 + 101 + payload := &structs.WorkflowDispatchPayload{ 102 + Inputs: inputs, 103 + Ref: entry.Ref, 104 + Repository: convert.ToRepo(ctx, repo, access.Permission{AccessMode: perm.AccessModeNone}), 105 + Sender: convert.ToUser(ctx, doer, nil), 106 + Workflow: fullWorkflowID, 107 + } 108 + 109 + p, err := json.Marshal(payload) 110 + if err != nil { 111 + return err 112 + } 113 + 114 + run := &actions_model.ActionRun{ 115 + Title: title, 116 + RepoID: repo.ID, 117 + Repo: repo, 118 + OwnerID: repo.OwnerID, 119 + WorkflowID: entry.WorkflowID, 120 + TriggerUserID: doer.ID, 121 + TriggerUser: doer, 122 + Ref: entry.Ref, 123 + CommitSHA: entry.Commit.ID.String(), 124 + Event: webhook.HookEventWorkflowDispatch, 125 + EventPayload: string(p), 126 + TriggerEvent: string(webhook.HookEventWorkflowDispatch), 127 + Status: actions_model.StatusWaiting, 128 + } 129 + 130 + vars, err := actions_model.GetVariablesOfRun(ctx, run) 131 + if err != nil { 132 + return err 133 + } 134 + 135 + jobs, err := jobparser.Parse(content, jobparser.WithVars(vars)) 136 + if err != nil { 137 + return err 138 + } 139 + 140 + return actions_model.InsertRun(ctx, run, jobs) 141 + } 142 + 143 + func GetWorkflowFromCommit(gitRepo *git.Repository, ref, workflowID string) (*Workflow, error) { 144 + commit, err := gitRepo.GetCommit(ref) 145 + if err != nil { 146 + return nil, err 147 + } 148 + 149 + entries, err := actions.ListWorkflows(commit) 150 + if err != nil { 151 + return nil, err 152 + } 153 + 154 + var workflowEntry *git.TreeEntry 155 + for _, entry := range entries { 156 + if entry.Name() == workflowID { 157 + workflowEntry = entry 158 + break 159 + } 160 + } 161 + if workflowEntry == nil { 162 + return nil, errors.New("workflow not found") 163 + } 164 + 165 + return &Workflow{ 166 + WorkflowID: workflowID, 167 + Ref: ref, 168 + Commit: commit, 169 + GitEntry: workflowEntry, 170 + }, nil 171 + }
+99
templates/repo/actions/dispatch.tmpl
··· 1 + <div class="ui info message tw-flex tw-items-center"> 2 + <span> 3 + {{ctx.Locale.Tr "actions.workflow.dispatch.trigger_found"}} 4 + </span> 5 + <div class="ui dropdown custom tw-ml-4" id="workflow_dispatch_dropdown"> 6 + <button class="ui compact small basic button tw-flex"> 7 + <span class="text">{{ctx.Locale.Tr "actions.workflow.dispatch.run"}}</span> 8 + {{svg "octicon-triangle-down" 14 "dropdown icon"}} 9 + </button> 10 + <div class="menu"> 11 + <div class="message ui form"> 12 + <div class="field"> 13 + <label>{{ctx.Locale.Tr "actions.workflow.dispatch.use_from"}}</label> 14 + {{template "repo/branch_dropdown" dict 15 + "root" (dict 16 + "IsViewBranch" true 17 + "BranchName" .Repo.BranchName 18 + "CommitID" .Repo.CommitID 19 + "RepoLink" .Repo.RepoLink 20 + "Repository" .Repo.Repository 21 + ) 22 + "disableCreateBranch" true 23 + "branchForm" "branch-dropdown-form" 24 + "setAction" false 25 + "submitForm" false 26 + }} 27 + </div> 28 + 29 + <form method="post" action="{{.Repo.RepoLink}}/actions/manual" id="branch-dropdown-form"> 30 + {{range $i, $key := .CurWorkflowDispatchInputKeys}} 31 + {{$val := index $.CurWorkflowDispatch.Inputs $key}} 32 + <div class="{{if $val.Required}}required {{end}}field"> 33 + {{if eq $val.Type "boolean"}} 34 + <div class="ui checkbox"> 35 + <label><strong>{{if $val.Description}}{{$val.Description}}{{else}}{{$key}}{{end}}</strong></label> 36 + <input {{if $val.Required}}required{{end}} type="checkbox" name="inputs[{{$key}}]" {{if eq $val.Default "true"}}checked{{end}}> 37 + </div> 38 + {{else}} 39 + <label>{{if $val.Description}}{{$val.Description}}{{else}}{{$key}}{{end}}</label> 40 + {{if eq $val.Type "number"}} 41 + <input {{if $val.Required}}required{{end}} type="number" name="inputs[{{$key}}]" {{if $val.Default}}value="{{$val.Default}}"{{end}}> 42 + {{else if eq $val.Type "string"}} 43 + <input {{if $val.Required}}required{{end}} type="text" name="inputs[{{$key}}]" {{if $val.Default}}value="{{$val.Default}}"{{end}}> 44 + {{else if eq $val.Type "choice"}} 45 + <div class="ui selection dropdown"> 46 + <input name="inputs[{{$key}}]" type="hidden" value="{{$val.Default}}"> 47 + {{svg "octicon-triangle-down" 14 "dropdown icon"}} 48 + <div class="text"></div> 49 + <div class="menu"> 50 + {{range $opt := $val.Options}} 51 + <div data-value="{{$opt}}" class="{{if eq $val.Default $opt}}active selected {{end}}item">{{$opt}}</div> 52 + {{end}} 53 + </div> 54 + </div> 55 + {{else}} 56 + <strong>{{ctx.Locale.Tr "actions.workflow.dispatch.invalid_input_type" $val.Type}}</strong> 57 + {{end}} 58 + {{end}} 59 + </div> 60 + {{end}} 61 + 62 + {{if .WarnDispatchInputsLimit}} 63 + <div class="text yellow tw-mb-4"> 64 + {{svg "octicon-alert"}} {{ctx.Locale.Tr "actions.workflow.dispatch.warn_input_limit" .DispatchInputsLimit}} 65 + </div> 66 + {{end}} 67 + 68 + {{.CsrfTokenHtml}} 69 + <input type="hidden" name="ref" value="{{if $.Repo.BranchName}}{{$.Repo.BranchName}}{{else}}{{$.Repo.Repository.DefaultBranch}}{{end}}"> 70 + <input type="hidden" name="workflow" value="{{$.CurWorkflow}}"> 71 + <input type="hidden" name="actor" value="{{$.CurActor}}"> 72 + <input type="hidden" name="status" value="{{$.CurStatus}}"> 73 + <button type="submit" id="workflow-dispatch-submit" class="ui primary small compact button">{{ctx.Locale.Tr "actions.workflow.dispatch.run"}}</button> 74 + </form> 75 + </div> 76 + </div> 77 + </div> 78 + <script> 79 + window.addEventListener('load', () => { 80 + const dropdown = $('#workflow_dispatch_dropdown'); 81 + const menu = dropdown.find('> .menu'); 82 + $(document.body).on('click', (ev) => { 83 + if (!dropdown[0].contains(ev.target) && menu.hasClass('visible')) { 84 + menu.transition({ animation: 'slide down out', duration: 200, queue: false }); 85 + } 86 + }); 87 + dropdown.on('click', (ev) => { 88 + const inMenu = $(ev.target).closest(menu).length !== 0; 89 + if (inMenu) return; 90 + ev.stopPropagation(); 91 + if (menu.hasClass('visible')) { 92 + menu.transition({ animation: 'slide down out', duration: 200, queue: false }); 93 + } else { 94 + menu.transition({ animation: 'slide down in', duration: 200, queue: true }); 95 + } 96 + }); 97 + }); 98 + </script> 99 + </div>
+5
templates/repo/actions/list.tmpl
··· 76 76 </button> 77 77 {{end}} 78 78 </div> 79 + 80 + {{if $.CurWorkflowDispatch}} 81 + {{template "repo/actions/dispatch" .}} 82 + {{end}} 83 + 79 84 {{template "repo/actions/runs_list" .}} 80 85 </div> 81 86 </div>
+74 -1
templates/swagger/v1_json.tmpl
··· 4239 4239 } 4240 4240 } 4241 4241 }, 4242 + "/repos/{owner}/{repo}/actions/workflows/{workflowname}/dispatches": { 4243 + "post": { 4244 + "consumes": [ 4245 + "application/json" 4246 + ], 4247 + "tags": [ 4248 + "repository" 4249 + ], 4250 + "summary": "Dispatches a workflow", 4251 + "operationId": "DispatchWorkflow", 4252 + "parameters": [ 4253 + { 4254 + "type": "string", 4255 + "description": "owner of the repo", 4256 + "name": "owner", 4257 + "in": "path", 4258 + "required": true 4259 + }, 4260 + { 4261 + "type": "string", 4262 + "description": "name of the repo", 4263 + "name": "repo", 4264 + "in": "path", 4265 + "required": true 4266 + }, 4267 + { 4268 + "type": "string", 4269 + "description": "name of the workflow", 4270 + "name": "workflowname", 4271 + "in": "path", 4272 + "required": true 4273 + }, 4274 + { 4275 + "name": "body", 4276 + "in": "body", 4277 + "schema": { 4278 + "$ref": "#/definitions/DispatchWorkflowOption" 4279 + } 4280 + } 4281 + ], 4282 + "responses": { 4283 + "204": { 4284 + "$ref": "#/responses/empty" 4285 + }, 4286 + "404": { 4287 + "$ref": "#/responses/notFound" 4288 + } 4289 + } 4290 + } 4291 + }, 4242 4292 "/repos/{owner}/{repo}/activities/feeds": { 4243 4293 "get": { 4244 4294 "produces": [ ··· 20902 20952 }, 20903 20953 "x-go-package": "code.gitea.io/gitea/modules/structs" 20904 20954 }, 20955 + "DispatchWorkflowOption": { 20956 + "description": "DispatchWorkflowOption options when dispatching a workflow", 20957 + "type": "object", 20958 + "required": [ 20959 + "ref" 20960 + ], 20961 + "properties": { 20962 + "inputs": { 20963 + "description": "Input keys and values configured in the workflow file.", 20964 + "type": "object", 20965 + "additionalProperties": { 20966 + "type": "string" 20967 + }, 20968 + "x-go-name": "Inputs" 20969 + }, 20970 + "ref": { 20971 + "description": "Git reference for the workflow", 20972 + "type": "string", 20973 + "x-go-name": "Ref" 20974 + } 20975 + }, 20976 + "x-go-package": "code.gitea.io/gitea/modules/structs" 20977 + }, 20905 20978 "EditAttachmentOptions": { 20906 20979 "description": "EditAttachmentOptions options for editing attachments", 20907 20980 "type": "object", ··· 26627 26700 "parameterBodies": { 26628 26701 "description": "parameterBodies", 26629 26702 "schema": { 26630 - "$ref": "#/definitions/UpdateVariableOption" 26703 + "$ref": "#/definitions/DispatchWorkflowOption" 26631 26704 } 26632 26705 }, 26633 26706 "redirect": {
+74
tests/e2e/actions.test.e2e.js
··· 1 + // @ts-check 2 + import {test, expect} from '@playwright/test'; 3 + import {login_user, load_logged_in_context} from './utils_e2e.js'; 4 + 5 + test.beforeAll(async ({browser}, workerInfo) => { 6 + await login_user(browser, workerInfo, 'user2'); 7 + }); 8 + 9 + test('Test workflow dispatch present', async ({browser}, workerInfo) => { 10 + const context = await load_logged_in_context(browser, workerInfo, 'user2'); 11 + /** @type {import('@playwright/test').Page} */ 12 + const page = await context.newPage(); 13 + 14 + await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); 15 + 16 + await expect(page.getByText('This workflow has a workflow_dispatch event trigger.')).toBeVisible(); 17 + 18 + const run_workflow_btn = page.locator('#workflow_dispatch_dropdown>button'); 19 + await expect(run_workflow_btn).toBeVisible(); 20 + 21 + const menu = page.locator('#workflow_dispatch_dropdown>.menu'); 22 + await expect(menu).toBeHidden(); 23 + await run_workflow_btn.click(); 24 + await expect(menu).toBeVisible(); 25 + }); 26 + 27 + test('Test workflow dispatch error: missing inputs', async ({browser}, workerInfo) => { 28 + test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383'); 29 + 30 + const context = await load_logged_in_context(browser, workerInfo, 'user2'); 31 + /** @type {import('@playwright/test').Page} */ 32 + const page = await context.newPage(); 33 + 34 + await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); 35 + await page.waitForLoadState('networkidle'); 36 + 37 + await page.locator('#workflow_dispatch_dropdown>button').click(); 38 + 39 + await page.waitForTimeout(1000); 40 + 41 + // Remove the required attribute so we can trigger the error message! 42 + await page.evaluate(() => { 43 + // eslint-disable-next-line no-undef 44 + const elem = document.querySelector('input[name="inputs[string2]"]'); 45 + elem?.removeAttribute('required'); 46 + }); 47 + 48 + await page.locator('#workflow-dispatch-submit').click(); 49 + await page.waitForLoadState('networkidle'); 50 + 51 + await expect(page.getByText('Require value for input "String w/o. default".')).toBeVisible(); 52 + }); 53 + 54 + test('Test workflow dispatch success', async ({browser}, workerInfo) => { 55 + test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383'); 56 + 57 + const context = await load_logged_in_context(browser, workerInfo, 'user2'); 58 + /** @type {import('@playwright/test').Page} */ 59 + const page = await context.newPage(); 60 + 61 + await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); 62 + await page.waitForLoadState('networkidle'); 63 + 64 + await page.locator('#workflow_dispatch_dropdown>button').click(); 65 + await page.waitForTimeout(1000); 66 + 67 + await page.type('input[name="inputs[string2]"]', 'abc'); 68 + await page.locator('#workflow-dispatch-submit').click(); 69 + await page.waitForLoadState('networkidle'); 70 + 71 + await expect(page.getByText('Workflow run was successfully requested.')).toBeVisible(); 72 + 73 + await expect(page.locator('.run-list>:first-child .run-list-meta', {hasText: 'now'})).toBeVisible(); 74 + });
+1
tests/gitea-repositories-meta/user2/test_workflows.git/HEAD
··· 1 + ref: refs/heads/main
+4
tests/gitea-repositories-meta/user2/test_workflows.git/config
··· 1 + [core] 2 + repositoryformatversion = 0 3 + filemode = true 4 + bare = true
+1
tests/gitea-repositories-meta/user2/test_workflows.git/description
··· 1 + Unnamed repository; edit this file 'description' to name the repository.
+6
tests/gitea-repositories-meta/user2/test_workflows.git/info/exclude
··· 1 + # git ls-files --others --exclude-from=.git/info/exclude 2 + # Lines that start with '#' are comments. 3 + # For a project mostly in C, the following would be a good set of 4 + # exclude patterns (uncomment them if you want to use them): 5 + # *.[oa] 6 + # *~
tests/gitea-repositories-meta/user2/test_workflows.git/objects/26/c8f930a36802d9cfb9785ca88704b1f52347aa

This is a binary file and will not be displayed.

tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/7f57e0a452699a5d2da0e42dcb2375de546c0a

This is a binary file and will not be displayed.

tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/89b2afa3e19e924330b4307a181714a4179010

This is a binary file and will not be displayed.

tests/gitea-repositories-meta/user2/test_workflows.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904

This is a binary file and will not be displayed.

tests/gitea-repositories-meta/user2/test_workflows.git/objects/77/4f93df12d14931ea93259ae93418da4482fcc1

This is a binary file and will not be displayed.

tests/gitea-repositories-meta/user2/test_workflows.git/objects/96/63cd4783a54f3e57b2dd908b077cf8126c826c

This is a binary file and will not be displayed.

+3
tests/gitea-repositories-meta/user2/test_workflows.git/packed-refs
··· 1 + # pack-refs with: peeled fully-peeled sorted 2 + 774f93df12d14931ea93259ae93418da4482fcc1 refs/heads/main 3 + 774f93df12d14931ea93259ae93418da4482fcc1 refs/heads/master
+43
tests/integration/actions_trigger_test.go
··· 396 396 assert.NotNil(t, run) 397 397 }) 398 398 } 399 + 400 + func TestWorkflowDispatchEvent(t *testing.T) { 401 + onGiteaRun(t, func(t *testing.T, u *url.URL) { 402 + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 403 + 404 + // create the repo 405 + repo, sha, f := CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch", 406 + []unit_model.Type{unit_model.TypeActions}, nil, 407 + []*files_service.ChangeRepoFile{ 408 + { 409 + Operation: "create", 410 + TreePath: ".gitea/workflows/dispatch.yml", 411 + ContentReader: strings.NewReader( 412 + "name: test\n" + 413 + "on: [workflow_dispatch]\n" + 414 + "jobs:\n" + 415 + " test:\n" + 416 + " runs-on: ubuntu-latest\n" + 417 + " steps:\n" + 418 + " - run: echo helloworld\n", 419 + ), 420 + }, 421 + }, 422 + ) 423 + defer f() 424 + 425 + gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo) 426 + assert.NoError(t, err) 427 + defer gitRepo.Close() 428 + 429 + workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, sha, "dispatch.yml") 430 + assert.NoError(t, err) 431 + 432 + inputGetter := func(key string) string { 433 + return "" 434 + } 435 + 436 + err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2) 437 + assert.NoError(t, err) 438 + 439 + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) 440 + }) 441 + }
+3 -3
tests/integration/api_repo_test.go
··· 95 95 }{ 96 96 { 97 97 name: "RepositoriesMax50", requestURL: "/api/v1/repos/search?limit=50&private=false", expectedResults: expectedResults{ 98 - nil: {count: 36}, 99 - user: {count: 36}, 100 - user2: {count: 36}, 98 + nil: {count: 37}, 99 + user: {count: 37}, 100 + user2: {count: 37}, 101 101 }, 102 102 }, 103 103 {
+13
web_src/css/actions.css
··· 81 81 max-width: 110px; 82 82 } 83 83 } 84 + 85 + #workflow_dispatch_dropdown { 86 + min-width: min-content; 87 + } 88 + #workflow_dispatch_dropdown > button { 89 + white-space: nowrap; 90 + } 91 + @media (max-width: 640px) or (767.98px < width < 854px) { 92 + #workflow_dispatch_dropdown .menu { 93 + left: auto; 94 + right: 0; 95 + } 96 + }