mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-08 18:04:14 +01:00
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:
parent
544cbc6f01
commit
51735c415b
39 changed files with 792 additions and 16 deletions
|
@ -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
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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"`
|
||||||
|
|
15
modules/structs/workflow.go
Normal file
15
modules/structs/workflow.go
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
// Copyright The Forgejo Authors.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package structs
|
||||||
|
|
||||||
|
// DispatchWorkflowOption options when dispatching a workflow
|
||||||
|
// swagger:model
|
||||||
|
type DispatchWorkflowOption struct {
|
||||||
|
// Git reference for the workflow
|
||||||
|
//
|
||||||
|
// required: true
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
// Input keys and values configured in the workflow file.
|
||||||
|
Inputs map[string]string `json:"inputs"`
|
||||||
|
}
|
|
@ -32,6 +32,7 @@ const (
|
||||||
HookEventRelease HookEventType = "release"
|
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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
1
release-notes/8.0.0/3334.md
Normal file
1
release-notes/8.0.0/3334.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Added support for the `workflow_dispatch` workflow trigger
|
|
@ -1123,6 +1123,12 @@ func Routes() *web.Route {
|
||||||
}, reqToken(), reqAdmin())
|
}, 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).
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -216,4 +216,7 @@ type swaggerParameterBodies struct {
|
||||||
|
|
||||||
// in:body
|
// in:body
|
||||||
UpdateVariableOption api.UpdateVariableOption
|
UpdateVariableOption api.UpdateVariableOption
|
||||||
|
|
||||||
|
// in:body
|
||||||
|
DispatchWorkflowOption api.DispatchWorkflowOption
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
62
routers/web/repo/actions/manual.go
Normal file
62
routers/web/repo/actions/manual.go
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
// Copyright The Forgejo Authors.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
actions_service "code.gitea.io/gitea/services/actions"
|
||||||
|
context_module "code.gitea.io/gitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ManualRunWorkflow(ctx *context_module.Context) {
|
||||||
|
workflowID := ctx.FormString("workflow")
|
||||||
|
if len(workflowID) == 0 {
|
||||||
|
ctx.ServerError("workflow", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := ctx.FormString("ref")
|
||||||
|
if len(ref) == 0 {
|
||||||
|
ctx.ServerError("ref", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil {
|
||||||
|
ctx.ServerError("IsEmpty", err)
|
||||||
|
return
|
||||||
|
} else if empty {
|
||||||
|
ctx.NotFound("IsEmpty", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow, err := actions_service.GetWorkflowFromCommit(ctx.Repo.GitRepo, ref, workflowID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetWorkflowFromCommit", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
location := ctx.Repo.RepoLink + "/actions?workflow=" + url.QueryEscape(workflowID) +
|
||||||
|
"&actor=" + url.QueryEscape(ctx.FormString("actor")) +
|
||||||
|
"&status=" + url.QueryEscape(ctx.FormString("status"))
|
||||||
|
|
||||||
|
formKeyGetter := func(key string) string {
|
||||||
|
formKey := "inputs[" + key + "]"
|
||||||
|
return ctx.FormString(formKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := workflow.Dispatch(ctx, formKeyGetter, ctx.Repo.Repository, ctx.Doer); err != nil {
|
||||||
|
if actions_service.IsInputRequiredErr(err) {
|
||||||
|
ctx.Flash.Error(ctx.Locale.Tr("actions.workflow.dispatch.input_required", err.(actions_service.InputRequiredErr).Name))
|
||||||
|
ctx.Redirect(location)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.ServerError("workflow.Dispatch", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// forward to the page of the run which was just created
|
||||||
|
ctx.Flash.Info(ctx.Locale.Tr("actions.workflow.dispatch.success"))
|
||||||
|
ctx.Redirect(location)
|
||||||
|
}
|
|
@ -1376,6 +1376,7 @@ func registerRoutes(m *web.Route) {
|
||||||
m.Get("", actions.List)
|
m.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)
|
||||||
|
|
171
services/actions/workflows.go
Normal file
171
services/actions/workflows.go
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
// Copyright The Forgejo Authors.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
|
"code.gitea.io/gitea/models/perm"
|
||||||
|
"code.gitea.io/gitea/models/perm/access"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/actions"
|
||||||
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
"code.gitea.io/gitea/modules/json"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/webhook"
|
||||||
|
"code.gitea.io/gitea/services/convert"
|
||||||
|
|
||||||
|
"github.com/nektos/act/pkg/jobparser"
|
||||||
|
act_model "github.com/nektos/act/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InputRequiredErr struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err InputRequiredErr) Error() string {
|
||||||
|
return fmt.Sprintf("input required for '%s'", err.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsInputRequiredErr(err error) bool {
|
||||||
|
_, ok := err.(InputRequiredErr)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
type Workflow struct {
|
||||||
|
WorkflowID string
|
||||||
|
Ref string
|
||||||
|
Commit *git.Commit
|
||||||
|
GitEntry *git.TreeEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputValueGetter func(key string) string
|
||||||
|
|
||||||
|
func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGetter, repo *repo_model.Repository, doer *user.User) error {
|
||||||
|
content, err := actions.GetContentFromEntry(entry.GitEntry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fullWorkflowID := ".forgejo/workflows/" + entry.WorkflowID
|
||||||
|
|
||||||
|
title := wf.Name
|
||||||
|
if len(title) < 1 {
|
||||||
|
title = fullWorkflowID
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs := make(map[string]string)
|
||||||
|
if workflowDispatch := wf.WorkflowDispatchConfig(); workflowDispatch != nil {
|
||||||
|
for key, input := range workflowDispatch.Inputs {
|
||||||
|
val := inputGetter(key)
|
||||||
|
if len(val) == 0 {
|
||||||
|
val = input.Default
|
||||||
|
if len(val) == 0 {
|
||||||
|
if input.Required {
|
||||||
|
name := input.Description
|
||||||
|
if len(name) == 0 {
|
||||||
|
name = key
|
||||||
|
}
|
||||||
|
return InputRequiredErr{Name: name}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch input.Type {
|
||||||
|
case "boolean":
|
||||||
|
// Since "boolean" inputs are rendered as a checkbox in html, the value inside the form is "on"
|
||||||
|
val = strconv.FormatBool(val == "on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inputs[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(inputs)) > setting.Actions.LimitDispatchInputs {
|
||||||
|
return errors.New("to many inputs")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &structs.WorkflowDispatchPayload{
|
||||||
|
Inputs: inputs,
|
||||||
|
Ref: entry.Ref,
|
||||||
|
Repository: convert.ToRepo(ctx, repo, access.Permission{AccessMode: perm.AccessModeNone}),
|
||||||
|
Sender: convert.ToUser(ctx, doer, nil),
|
||||||
|
Workflow: fullWorkflowID,
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
run := &actions_model.ActionRun{
|
||||||
|
Title: title,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Repo: repo,
|
||||||
|
OwnerID: repo.OwnerID,
|
||||||
|
WorkflowID: entry.WorkflowID,
|
||||||
|
TriggerUserID: doer.ID,
|
||||||
|
TriggerUser: doer,
|
||||||
|
Ref: entry.Ref,
|
||||||
|
CommitSHA: entry.Commit.ID.String(),
|
||||||
|
Event: webhook.HookEventWorkflowDispatch,
|
||||||
|
EventPayload: string(p),
|
||||||
|
TriggerEvent: string(webhook.HookEventWorkflowDispatch),
|
||||||
|
Status: actions_model.StatusWaiting,
|
||||||
|
}
|
||||||
|
|
||||||
|
vars, err := actions_model.GetVariablesOfRun(ctx, run)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs, err := jobparser.Parse(content, jobparser.WithVars(vars))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions_model.InsertRun(ctx, run, jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetWorkflowFromCommit(gitRepo *git.Repository, ref, workflowID string) (*Workflow, error) {
|
||||||
|
commit, err := gitRepo.GetCommit(ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := actions.ListWorkflows(commit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var workflowEntry *git.TreeEntry
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Name() == workflowID {
|
||||||
|
workflowEntry = entry
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if workflowEntry == nil {
|
||||||
|
return nil, errors.New("workflow not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Workflow{
|
||||||
|
WorkflowID: workflowID,
|
||||||
|
Ref: ref,
|
||||||
|
Commit: commit,
|
||||||
|
GitEntry: workflowEntry,
|
||||||
|
}, nil
|
||||||
|
}
|
99
templates/repo/actions/dispatch.tmpl
Normal file
99
templates/repo/actions/dispatch.tmpl
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
<div class="ui info message tw-flex tw-items-center">
|
||||||
|
<span>
|
||||||
|
{{ctx.Locale.Tr "actions.workflow.dispatch.trigger_found"}}
|
||||||
|
</span>
|
||||||
|
<div class="ui dropdown custom tw-ml-4" id="workflow_dispatch_dropdown">
|
||||||
|
<button class="ui compact small basic button tw-flex">
|
||||||
|
<span class="text">{{ctx.Locale.Tr "actions.workflow.dispatch.run"}}</span>
|
||||||
|
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||||
|
</button>
|
||||||
|
<div class="menu">
|
||||||
|
<div class="message ui form">
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "actions.workflow.dispatch.use_from"}}</label>
|
||||||
|
{{template "repo/branch_dropdown" dict
|
||||||
|
"root" (dict
|
||||||
|
"IsViewBranch" true
|
||||||
|
"BranchName" .Repo.BranchName
|
||||||
|
"CommitID" .Repo.CommitID
|
||||||
|
"RepoLink" .Repo.RepoLink
|
||||||
|
"Repository" .Repo.Repository
|
||||||
|
)
|
||||||
|
"disableCreateBranch" true
|
||||||
|
"branchForm" "branch-dropdown-form"
|
||||||
|
"setAction" false
|
||||||
|
"submitForm" false
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{.Repo.RepoLink}}/actions/manual" id="branch-dropdown-form">
|
||||||
|
{{range $i, $key := .CurWorkflowDispatchInputKeys}}
|
||||||
|
{{$val := index $.CurWorkflowDispatch.Inputs $key}}
|
||||||
|
<div class="{{if $val.Required}}required {{end}}field">
|
||||||
|
{{if eq $val.Type "boolean"}}
|
||||||
|
<div class="ui checkbox">
|
||||||
|
<label><strong>{{if $val.Description}}{{$val.Description}}{{else}}{{$key}}{{end}}</strong></label>
|
||||||
|
<input {{if $val.Required}}required{{end}} type="checkbox" name="inputs[{{$key}}]" {{if eq $val.Default "true"}}checked{{end}}>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<label>{{if $val.Description}}{{$val.Description}}{{else}}{{$key}}{{end}}</label>
|
||||||
|
{{if eq $val.Type "number"}}
|
||||||
|
<input {{if $val.Required}}required{{end}} type="number" name="inputs[{{$key}}]" {{if $val.Default}}value="{{$val.Default}}"{{end}}>
|
||||||
|
{{else if eq $val.Type "string"}}
|
||||||
|
<input {{if $val.Required}}required{{end}} type="text" name="inputs[{{$key}}]" {{if $val.Default}}value="{{$val.Default}}"{{end}}>
|
||||||
|
{{else if eq $val.Type "choice"}}
|
||||||
|
<div class="ui selection dropdown">
|
||||||
|
<input name="inputs[{{$key}}]" type="hidden" value="{{$val.Default}}">
|
||||||
|
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||||
|
<div class="text"></div>
|
||||||
|
<div class="menu">
|
||||||
|
{{range $opt := $val.Options}}
|
||||||
|
<div data-value="{{$opt}}" class="{{if eq $val.Default $opt}}active selected {{end}}item">{{$opt}}</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<strong>{{ctx.Locale.Tr "actions.workflow.dispatch.invalid_input_type" $val.Type}}</strong>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .WarnDispatchInputsLimit}}
|
||||||
|
<div class="text yellow tw-mb-4">
|
||||||
|
{{svg "octicon-alert"}} {{ctx.Locale.Tr "actions.workflow.dispatch.warn_input_limit" .DispatchInputsLimit}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{.CsrfTokenHtml}}
|
||||||
|
<input type="hidden" name="ref" value="{{if $.Repo.BranchName}}{{$.Repo.BranchName}}{{else}}{{$.Repo.Repository.DefaultBranch}}{{end}}">
|
||||||
|
<input type="hidden" name="workflow" value="{{$.CurWorkflow}}">
|
||||||
|
<input type="hidden" name="actor" value="{{$.CurActor}}">
|
||||||
|
<input type="hidden" name="status" value="{{$.CurStatus}}">
|
||||||
|
<button type="submit" id="workflow-dispatch-submit" class="ui primary small compact button">{{ctx.Locale.Tr "actions.workflow.dispatch.run"}}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const dropdown = $('#workflow_dispatch_dropdown');
|
||||||
|
const menu = dropdown.find('> .menu');
|
||||||
|
$(document.body).on('click', (ev) => {
|
||||||
|
if (!dropdown[0].contains(ev.target) && menu.hasClass('visible')) {
|
||||||
|
menu.transition({ animation: 'slide down out', duration: 200, queue: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dropdown.on('click', (ev) => {
|
||||||
|
const inMenu = $(ev.target).closest(menu).length !== 0;
|
||||||
|
if (inMenu) return;
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (menu.hasClass('visible')) {
|
||||||
|
menu.transition({ animation: 'slide down out', duration: 200, queue: false });
|
||||||
|
} else {
|
||||||
|
menu.transition({ animation: 'slide down in', duration: 200, queue: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
|
@ -76,6 +76,11 @@
|
||||||
</button>
|
</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>
|
||||||
|
|
75
templates/swagger/v1_json.tmpl
generated
75
templates/swagger/v1_json.tmpl
generated
|
@ -4239,6 +4239,56 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/actions/workflows/{workflowname}/dispatches": {
|
||||||
|
"post": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"summary": "Dispatches a workflow",
|
||||||
|
"operationId": "DispatchWorkflow",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repo",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the workflow",
|
||||||
|
"name": "workflowname",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/DispatchWorkflowOption"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"$ref": "#/responses/empty"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/activities/feeds": {
|
"/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": {
|
||||||
|
|
74
tests/e2e/actions.test.e2e.js
Normal file
74
tests/e2e/actions.test.e2e.js
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
// @ts-check
|
||||||
|
import {test, expect} from '@playwright/test';
|
||||||
|
import {login_user, load_logged_in_context} from './utils_e2e.js';
|
||||||
|
|
||||||
|
test.beforeAll(async ({browser}, workerInfo) => {
|
||||||
|
await login_user(browser, workerInfo, 'user2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Test workflow dispatch present', async ({browser}, workerInfo) => {
|
||||||
|
const context = await load_logged_in_context(browser, workerInfo, 'user2');
|
||||||
|
/** @type {import('@playwright/test').Page} */
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
|
||||||
|
|
||||||
|
await expect(page.getByText('This workflow has a workflow_dispatch event trigger.')).toBeVisible();
|
||||||
|
|
||||||
|
const run_workflow_btn = page.locator('#workflow_dispatch_dropdown>button');
|
||||||
|
await expect(run_workflow_btn).toBeVisible();
|
||||||
|
|
||||||
|
const menu = page.locator('#workflow_dispatch_dropdown>.menu');
|
||||||
|
await expect(menu).toBeHidden();
|
||||||
|
await run_workflow_btn.click();
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Test workflow dispatch error: missing inputs', async ({browser}, workerInfo) => {
|
||||||
|
test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
|
||||||
|
|
||||||
|
const context = await load_logged_in_context(browser, workerInfo, 'user2');
|
||||||
|
/** @type {import('@playwright/test').Page} */
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await page.locator('#workflow_dispatch_dropdown>button').click();
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Remove the required attribute so we can trigger the error message!
|
||||||
|
await page.evaluate(() => {
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const elem = document.querySelector('input[name="inputs[string2]"]');
|
||||||
|
elem?.removeAttribute('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('#workflow-dispatch-submit').click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await expect(page.getByText('Require value for input "String w/o. default".')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Test workflow dispatch success', async ({browser}, workerInfo) => {
|
||||||
|
test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
|
||||||
|
|
||||||
|
const context = await load_logged_in_context(browser, workerInfo, 'user2');
|
||||||
|
/** @type {import('@playwright/test').Page} */
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await page.locator('#workflow_dispatch_dropdown>button').click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
await page.type('input[name="inputs[string2]"]', 'abc');
|
||||||
|
await page.locator('#workflow-dispatch-submit').click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await expect(page.getByText('Workflow run was successfully requested.')).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('.run-list>:first-child .run-list-meta', {hasText: 'now'})).toBeVisible();
|
||||||
|
});
|
|
@ -0,0 +1 @@
|
||||||
|
ref: refs/heads/main
|
|
@ -0,0 +1,4 @@
|
||||||
|
[core]
|
||||||
|
repositoryformatversion = 0
|
||||||
|
filemode = true
|
||||||
|
bare = true
|
|
@ -0,0 +1 @@
|
||||||
|
Unnamed repository; edit this file 'description' to name the repository.
|
|
@ -0,0 +1,6 @@
|
||||||
|
# git ls-files --others --exclude-from=.git/info/exclude
|
||||||
|
# Lines that start with '#' are comments.
|
||||||
|
# For a project mostly in C, the following would be a good set of
|
||||||
|
# exclude patterns (uncomment them if you want to use them):
|
||||||
|
# *.[oa]
|
||||||
|
# *~
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,3 @@
|
||||||
|
# pack-refs with: peeled fully-peeled sorted
|
||||||
|
774f93df12d14931ea93259ae93418da4482fcc1 refs/heads/main
|
||||||
|
774f93df12d14931ea93259ae93418da4482fcc1 refs/heads/master
|
|
@ -396,3 +396,46 @@ func TestCreateDeleteRefEvent(t *testing.T) {
|
||||||
assert.NotNil(t, run)
|
assert.NotNil(t, run)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkflowDispatchEvent(t *testing.T) {
|
||||||
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
|
||||||
|
// create the repo
|
||||||
|
repo, sha, f := CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch",
|
||||||
|
[]unit_model.Type{unit_model.TypeActions}, nil,
|
||||||
|
[]*files_service.ChangeRepoFile{
|
||||||
|
{
|
||||||
|
Operation: "create",
|
||||||
|
TreePath: ".gitea/workflows/dispatch.yml",
|
||||||
|
ContentReader: strings.NewReader(
|
||||||
|
"name: test\n" +
|
||||||
|
"on: [workflow_dispatch]\n" +
|
||||||
|
"jobs:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" runs-on: ubuntu-latest\n" +
|
||||||
|
" steps:\n" +
|
||||||
|
" - run: echo helloworld\n",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer f()
|
||||||
|
|
||||||
|
gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, sha, "dispatch.yml")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
inputGetter := func(key string) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -95,9 +95,9 @@ func TestAPISearchRepo(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "RepositoriesMax50", requestURL: "/api/v1/repos/search?limit=50&private=false", expectedResults: expectedResults{
|
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},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue