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>
This commit is contained in:
Mai-Lapyst 2024-06-28 05:17:11 +00:00 committed by Earl Warren
parent 544cbc6f01
commit 51735c415b
39 changed files with 792 additions and 16 deletions

View file

@ -2714,6 +2714,8 @@ LEVEL = Info
;ABANDONED_JOB_TIMEOUT = 24h ;ABANDONED_JOB_TIMEOUT = 24h
;; Strings committers can place inside a commit message or PR title to skip executing the corresponding actions workflow ;; 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] ;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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View file

@ -750,3 +750,41 @@
type: 3 type: 3
config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}" config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}"
created_unix: 946684810 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

View file

@ -1782,3 +1782,33 @@
size: 0 size: 0
is_fsck_enabled: true is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false 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

View file

@ -66,7 +66,7 @@
num_followers: 2 num_followers: 2
num_following: 1 num_following: 1
num_stars: 2 num_stars: 2
num_repos: 16 num_repos: 17
num_teams: 0 num_teams: 0
num_members: 0 num_members: 0
visibility: 0 visibility: 0

View file

@ -138,27 +138,27 @@ func getTestCases() []struct {
{ {
name: "AllPublic/PublicRepositoriesOfUserIncludingCollaborative", name: "AllPublic/PublicRepositoriesOfUserIncludingCollaborative",
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, AllPublic: true, Template: optional.Some(false)}, 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", 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)}, 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", name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborativeByName",
opts: &repo_model.SearchRepoOptions{Keyword: "test", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true}, 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", name: "AllPublic/PublicAndPrivateRepositoriesOfUser2IncludingCollaborativeByName",
opts: &repo_model.SearchRepoOptions{Keyword: "test", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18, Private: true, AllPublic: true}, 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", 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)}, 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", name: "AllTemplates",

View file

@ -23,6 +23,7 @@ const (
GithubEventPullRequestComment = "pull_request_comment" GithubEventPullRequestComment = "pull_request_comment"
GithubEventGollum = "gollum" GithubEventGollum = "gollum"
GithubEventSchedule = "schedule" GithubEventSchedule = "schedule"
GithubEventWorkflowDispatch = "workflow_dispatch"
) )
// IsDefaultBranchWorkflow returns true if the event only triggers workflows on the default branch // 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 // GitHub "schedule" event
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
return true 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, case webhook_module.HookEventIssues,
webhook_module.HookEventIssueAssign, webhook_module.HookEventIssueAssign,
webhook_module.HookEventIssueLabel, webhook_module.HookEventIssueLabel,
@ -74,6 +79,9 @@ func canGithubEventMatch(eventName string, triggedEvent webhook_module.HookEvent
case GithubEventGollum: case GithubEventGollum:
return triggedEvent == webhook_module.HookEventWiki return triggedEvent == webhook_module.HookEventWiki
case GithubEventWorkflowDispatch:
return triggedEvent == webhook_module.HookEventWorkflowDispatch
case GithubEventIssues: case GithubEventIssues:
switch triggedEvent { switch triggedEvent {
case webhook_module.HookEventIssues, case webhook_module.HookEventIssues,

View file

@ -191,6 +191,7 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
switch triggedEvent { switch triggedEvent {
case // events with no activity types case // events with no activity types
webhook_module.HookEventWorkflowDispatch,
webhook_module.HookEventCreate, webhook_module.HookEventCreate,
webhook_module.HookEventDelete, webhook_module.HookEventDelete,
webhook_module.HookEventFork, webhook_module.HookEventFork,

View file

@ -125,6 +125,13 @@ func TestDetectMatched(t *testing.T) {
yamlOn: "on: schedule", yamlOn: "on: schedule",
expected: true, 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 { for _, tc := range testCases {

View file

@ -21,10 +21,12 @@ var (
EndlessTaskTimeout time.Duration `ini:"ENDLESS_TASK_TIMEOUT"` EndlessTaskTimeout time.Duration `ini:"ENDLESS_TASK_TIMEOUT"`
AbandonedJobTimeout time.Duration `ini:"ABANDONED_JOB_TIMEOUT"` AbandonedJobTimeout time.Duration `ini:"ABANDONED_JOB_TIMEOUT"`
SkipWorkflowStrings []string `ìni:"SKIP_WORKFLOW_STRINGS"` SkipWorkflowStrings []string `ìni:"SKIP_WORKFLOW_STRINGS"`
LimitDispatchInputs int64 `ini:"LIMIT_DISPATCH_INPUTS"`
}{ }{
Enabled: true, Enabled: true,
DefaultActionsURL: defaultActionsURLForgejo, DefaultActionsURL: defaultActionsURLForgejo,
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"}, SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
LimitDispatchInputs: 10,
} }
) )

View file

@ -416,6 +416,14 @@ type SchedulePayload struct {
Action HookScheduleAction `json:"action"` 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 // ReviewPayload FIXME
type ReviewPayload struct { type ReviewPayload struct {
Type string `json:"type"` Type string `json:"type"`

View 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"`
}

View file

@ -32,6 +32,7 @@ const (
HookEventRelease HookEventType = "release" HookEventRelease HookEventType = "release"
HookEventPackage HookEventType = "package" HookEventPackage HookEventType = "package"
HookEventSchedule HookEventType = "schedule" HookEventSchedule HookEventType = "schedule"
HookEventWorkflowDispatch HookEventType = "workflow_dispatch"
) )
// Event returns the HookEventType as an event string // Event returns the HookEventType as an event string

View file

@ -3769,6 +3769,13 @@ workflow.disable_success = Workflow "%s" disabled successfully.
workflow.enable = Enable workflow workflow.enable = Enable workflow
workflow.enable_success = Workflow "%s" enabled successfully. workflow.enable_success = Workflow "%s" enabled successfully.
workflow.disabled = Workflow is disabled. 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. need_approval_desc = Need approval to run workflows for fork pull request.

View file

@ -0,0 +1 @@
Added support for the `workflow_dispatch` workflow trigger

View file

@ -1123,6 +1123,12 @@ func Routes() *web.Route {
}, reqToken(), reqAdmin()) }, reqToken(), reqAdmin())
m.Group("/actions", func() { m.Group("/actions", func() {
m.Get("/tasks", repo.ListActionTasks) 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)) }, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true))
m.Group("/keys", func() { m.Group("/keys", func() {
m.Combo("").Get(repo.ListDeployKeys). m.Combo("").Get(repo.ListDeployKeys).

View file

@ -583,3 +583,73 @@ func ListActionTasks(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, &res) 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)
}

View file

@ -216,4 +216,7 @@ type swaggerParameterBodies struct {
// in:body // in:body
UpdateVariableOption api.UpdateVariableOption UpdateVariableOption api.UpdateVariableOption
// in:body
DispatchWorkflowOption api.DispatchWorkflowOption
} }

View file

@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strings" "strings"
actions_model "code.gitea.io/gitea/models/actions" actions_model "code.gitea.io/gitea/models/actions"
@ -18,6 +19,7 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/web/repo" "code.gitea.io/gitea/routers/web/repo"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/convert"
@ -59,6 +61,9 @@ func List(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("actions.actions") ctx.Data["Title"] = ctx.Tr("actions.actions")
ctx.Data["PageIsActions"] = true ctx.Data["PageIsActions"] = true
curWorkflow := ctx.FormString("workflow")
ctx.Data["CurWorkflow"] = curWorkflow
var workflows []Workflow var workflows []Workflow
if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil {
ctx.ServerError("IsEmpty", err) ctx.ServerError("IsEmpty", err)
@ -140,6 +145,22 @@ func List(ctx *context.Context) {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job") workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job")
} }
workflows = append(workflows, workflow) 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 ctx.Data["workflows"] = workflows
@ -150,17 +171,15 @@ func List(ctx *context.Context) {
page = 1 page = 1
} }
workflow := ctx.FormString("workflow")
actorID := ctx.FormInt64("actor") actorID := ctx.FormInt64("actor")
status := ctx.FormInt("status") status := ctx.FormInt("status")
ctx.Data["CurWorkflow"] = workflow
actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
ctx.Data["ActionsConfig"] = 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["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") // 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")), PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
}, },
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
WorkflowID: workflow, WorkflowID: curWorkflow,
TriggerUserID: actorID, TriggerUserID: actorID,
} }
@ -203,6 +222,8 @@ func List(ctx *context.Context) {
ctx.Data["Runs"] = runs ctx.Data["Runs"] = runs
ctx.Data["Repo"] = ctx.Repo
actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID)
if err != nil { if err != nil {
ctx.ServerError("GetActors", err) ctx.ServerError("GetActors", err)
@ -214,7 +235,7 @@ func List(ctx *context.Context) {
pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5)
pager.SetDefaultParams(ctx) pager.SetDefaultParams(ctx)
pager.AddParamString("workflow", workflow) pager.AddParamString("workflow", curWorkflow)
pager.AddParamString("actor", fmt.Sprint(actorID)) pager.AddParamString("actor", fmt.Sprint(actorID))
pager.AddParamString("status", fmt.Sprint(status)) pager.AddParamString("status", fmt.Sprint(status))
ctx.Data["Page"] = pager ctx.Data["Page"] = pager

View 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)
}

View file

@ -1376,6 +1376,7 @@ func registerRoutes(m *web.Route) {
m.Get("", actions.List) m.Get("", actions.List)
m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile) m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile)
m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile)
m.Post("/manual", reqRepoAdmin, actions.ManualRunWorkflow)
m.Group("/runs", func() { m.Group("/runs", func() {
m.Get("/latest", actions.ViewLatest) m.Get("/latest", actions.ViewLatest)

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

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

View file

@ -76,6 +76,11 @@
</button> </button>
{{end}} {{end}}
</div> </div>
{{if $.CurWorkflowDispatch}}
{{template "repo/actions/dispatch" .}}
{{end}}
{{template "repo/actions/runs_list" .}} {{template "repo/actions/runs_list" .}}
</div> </div>
</div> </div>

View file

@ -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": { "/repos/{owner}/{repo}/activities/feeds": {
"get": { "get": {
"produces": [ "produces": [
@ -20902,6 +20952,29 @@
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "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": { "EditAttachmentOptions": {
"description": "EditAttachmentOptions options for editing attachments", "description": "EditAttachmentOptions options for editing attachments",
"type": "object", "type": "object",
@ -26627,7 +26700,7 @@
"parameterBodies": { "parameterBodies": {
"description": "parameterBodies", "description": "parameterBodies",
"schema": { "schema": {
"$ref": "#/definitions/UpdateVariableOption" "$ref": "#/definitions/DispatchWorkflowOption"
} }
}, },
"redirect": { "redirect": {

View 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();
});

View file

@ -0,0 +1 @@
ref: refs/heads/main

View file

@ -0,0 +1,4 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true

View file

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -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]
# *~

View file

@ -0,0 +1,3 @@
# pack-refs with: peeled fully-peeled sorted
774f93df12d14931ea93259ae93418da4482fcc1 refs/heads/main
774f93df12d14931ea93259ae93418da4482fcc1 refs/heads/master

View file

@ -396,3 +396,46 @@ func TestCreateDeleteRefEvent(t *testing.T) {
assert.NotNil(t, run) 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}))
})
}

View file

@ -95,9 +95,9 @@ func TestAPISearchRepo(t *testing.T) {
}{ }{
{ {
name: "RepositoriesMax50", requestURL: "/api/v1/repos/search?limit=50&private=false", expectedResults: expectedResults{ name: "RepositoriesMax50", requestURL: "/api/v1/repos/search?limit=50&private=false", expectedResults: expectedResults{
nil: {count: 36}, nil: {count: 37},
user: {count: 36}, user: {count: 37},
user2: {count: 36}, user2: {count: 37},
}, },
}, },
{ {

View file

@ -81,3 +81,16 @@
max-width: 110px; 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;
}
}