From 42377360296b7c810b284472ba6743bf684186fb Mon Sep 17 00:00:00 2001
From: ChristopherHX <christopher.homberger@web.de>
Date: Sun, 5 Jan 2025 14:47:18 +0100
Subject: [PATCH] workflow_dispatch use workflow from trigger branch (#33098)

* htmx updates the input form on branch switch
* add workflow warning to dispatch modal
* use name if description of input is empty
* show error if workflow_dispatch not available on branch

Closes #33073
Closes #33099

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 options/locale/locale_en-US.ini               |   1 +
 routers/web/repo/actions/actions.go           | 226 +++++++++++-------
 routers/web/repo/actions/view.go              |   9 +-
 routers/web/web.go                            |   3 +-
 templates/repo/actions/workflow_dispatch.tmpl |  27 +--
 .../actions/workflow_dispatch_inputs.tmpl     |  45 ++++
 6 files changed, 189 insertions(+), 122 deletions(-)
 create mode 100644 templates/repo/actions/workflow_dispatch_inputs.tmpl

diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 07c9ffa9fc..96404a6143 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -3765,6 +3765,7 @@ workflow.not_found = Workflow '%s' not found.
 workflow.run_success = Workflow '%s' run successfully.
 workflow.from_ref = Use workflow from
 workflow.has_workflow_dispatch = This workflow has a workflow_dispatch event trigger.
+workflow.has_no_workflow_dispatch = Workflow '%s' has no workflow_dispatch event trigger.
 
 need_approval_desc = Need approval to run workflows for fork pull request.
 
diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go
index f0d8d81fee..539c4b6ed0 100644
--- a/routers/web/repo/actions/actions.go
+++ b/routers/web/repo/actions/actions.go
@@ -32,8 +32,9 @@ import (
 )
 
 const (
-	tplListActions templates.TplName = "repo/actions/list"
-	tplViewActions templates.TplName = "repo/actions/view"
+	tplListActions           templates.TplName = "repo/actions/list"
+	tplDispatchInputsActions templates.TplName = "repo/actions/workflow_dispatch_inputs"
+	tplViewActions           templates.TplName = "repo/actions/view"
 )
 
 type Workflow struct {
@@ -64,107 +65,143 @@ func MustEnableActions(ctx *context.Context) {
 func List(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("actions.actions")
 	ctx.Data["PageIsActions"] = true
-	workflowID := ctx.FormString("workflow")
-	actorID := ctx.FormInt64("actor")
-	status := ctx.FormInt("status")
-	ctx.Data["CurWorkflow"] = workflowID
 
-	var workflows []Workflow
-	var curWorkflow *model.Workflow
-	if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil {
-		ctx.ServerError("IsEmpty", err)
+	commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
+	if err != nil {
+		ctx.ServerError("GetBranchCommit", err)
 		return
-	} else if !empty {
-		commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
-		if err != nil {
-			ctx.ServerError("GetBranchCommit", err)
-			return
-		}
-		entries, err := actions.ListWorkflows(commit)
-		if err != nil {
-			ctx.ServerError("ListWorkflows", err)
-			return
-		}
+	}
 
-		// Get all runner labels
-		runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
-			RepoID:        ctx.Repo.Repository.ID,
-			IsOnline:      optional.Some(true),
-			WithAvailable: true,
-		})
-		if err != nil {
-			ctx.ServerError("FindRunners", err)
-			return
-		}
-		allRunnerLabels := make(container.Set[string])
-		for _, r := range runners {
-			allRunnerLabels.AddMultiple(r.AgentLabels...)
-		}
+	workflows := prepareWorkflowDispatchTemplate(ctx, commit)
+	if ctx.Written() {
+		return
+	}
 
-		workflows = make([]Workflow, 0, len(entries))
-		for _, entry := range entries {
-			workflow := Workflow{Entry: *entry}
-			content, err := actions.GetContentFromEntry(entry)
-			if err != nil {
-				ctx.ServerError("GetContentFromEntry", err)
-				return
-			}
-			wf, err := model.ReadWorkflow(bytes.NewReader(content))
-			if err != nil {
-				workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
-				workflows = append(workflows, workflow)
+	prepareWorkflowList(ctx, workflows)
+	if ctx.Written() {
+		return
+	}
+
+	ctx.HTML(http.StatusOK, tplListActions)
+}
+
+func WorkflowDispatchInputs(ctx *context.Context) {
+	ref := ctx.FormString("ref")
+	if ref == "" {
+		ctx.NotFound("WorkflowDispatchInputs: no ref", nil)
+		return
+	}
+	// get target commit of run from specified ref
+	refName := git.RefName(ref)
+	var commit *git.Commit
+	var err error
+	if refName.IsTag() {
+		commit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName())
+	} else if refName.IsBranch() {
+		commit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName())
+	} else {
+		ctx.ServerError("UnsupportedRefType", nil)
+		return
+	}
+	if err != nil {
+		ctx.ServerError("GetTagCommit/GetBranchCommit", err)
+		return
+	}
+	prepareWorkflowDispatchTemplate(ctx, commit)
+	if ctx.Written() {
+		return
+	}
+	ctx.HTML(http.StatusOK, tplDispatchInputsActions)
+}
+
+func prepareWorkflowDispatchTemplate(ctx *context.Context, commit *git.Commit) (workflows []Workflow) {
+	workflowID := ctx.FormString("workflow")
+	ctx.Data["CurWorkflow"] = workflowID
+	ctx.Data["CurWorkflowExists"] = false
+
+	var curWorkflow *model.Workflow
+
+	entries, err := actions.ListWorkflows(commit)
+	if err != nil {
+		ctx.ServerError("ListWorkflows", err)
+		return nil
+	}
+
+	// Get all runner labels
+	runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
+		RepoID:        ctx.Repo.Repository.ID,
+		IsOnline:      optional.Some(true),
+		WithAvailable: true,
+	})
+	if err != nil {
+		ctx.ServerError("FindRunners", err)
+		return nil
+	}
+	allRunnerLabels := make(container.Set[string])
+	for _, r := range runners {
+		allRunnerLabels.AddMultiple(r.AgentLabels...)
+	}
+
+	workflows = make([]Workflow, 0, len(entries))
+	for _, entry := range entries {
+		workflow := Workflow{Entry: *entry}
+		content, err := actions.GetContentFromEntry(entry)
+		if err != nil {
+			ctx.ServerError("GetContentFromEntry", err)
+			return nil
+		}
+		wf, err := model.ReadWorkflow(bytes.NewReader(content))
+		if err != nil {
+			workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
+			workflows = append(workflows, workflow)
+			continue
+		}
+		// The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run.
+		hasJobWithoutNeeds := false
+		// Check whether you have matching runner and a job without "needs"
+		emptyJobsNumber := 0
+		for _, j := range wf.Jobs {
+			if j == nil {
+				emptyJobsNumber++
 				continue
 			}
-			// The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run.
-			hasJobWithoutNeeds := false
-			// Check whether have matching runner and a job without "needs"
-			emptyJobsNumber := 0
-			for _, j := range wf.Jobs {
-				if j == nil {
-					emptyJobsNumber++
+			if !hasJobWithoutNeeds && len(j.Needs()) == 0 {
+				hasJobWithoutNeeds = true
+			}
+			runsOnList := j.RunsOn()
+			for _, ro := range runsOnList {
+				if strings.Contains(ro, "${{") {
+					// Skip if it contains expressions.
+					// The expressions could be very complex and could not be evaluated here,
+					// so just skip it, it's OK since it's just a tooltip message.
 					continue
 				}
-				if !hasJobWithoutNeeds && len(j.Needs()) == 0 {
-					hasJobWithoutNeeds = true
-				}
-				runsOnList := j.RunsOn()
-				for _, ro := range runsOnList {
-					if strings.Contains(ro, "${{") {
-						// Skip if it contains expressions.
-						// The expressions could be very complex and could not be evaluated here,
-						// so just skip it, it's OK since it's just a tooltip message.
-						continue
-					}
-					if !allRunnerLabels.Contains(ro) {
-						workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", ro)
-						break
-					}
-				}
-				if workflow.ErrMsg != "" {
+				if !allRunnerLabels.Contains(ro) {
+					workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", ro)
 					break
 				}
 			}
-			if !hasJobWithoutNeeds {
-				workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs")
-			}
-			if emptyJobsNumber == len(wf.Jobs) {
-				workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job")
-			}
-			workflows = append(workflows, workflow)
-
-			if workflow.Entry.Name() == workflowID {
-				curWorkflow = wf
+			if workflow.ErrMsg != "" {
+				break
 			}
 		}
+		if !hasJobWithoutNeeds {
+			workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs")
+		}
+		if emptyJobsNumber == len(wf.Jobs) {
+			workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job")
+		}
+		workflows = append(workflows, workflow)
+
+		if workflow.Entry.Name() == workflowID {
+			curWorkflow = wf
+			ctx.Data["CurWorkflowExists"] = true
+		}
 	}
+
 	ctx.Data["workflows"] = workflows
 	ctx.Data["RepoLink"] = ctx.Repo.Repository.Link()
 
-	page := ctx.FormInt("page")
-	if page <= 0 {
-		page = 1
-	}
-
 	actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
 	ctx.Data["ActionsConfig"] = actionsConfig
 
@@ -188,7 +225,7 @@ func List(ctx *context.Context) {
 				branches, err := git_model.FindBranchNames(ctx, branchOpts)
 				if err != nil {
 					ctx.ServerError("FindBranchNames", err)
-					return
+					return nil
 				}
 				// always put default branch on the top if it exists
 				if slices.Contains(branches, ctx.Repo.Repository.DefaultBranch) {
@@ -200,12 +237,23 @@ func List(ctx *context.Context) {
 				tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
 				if err != nil {
 					ctx.ServerError("GetTagNamesByRepoID", err)
-					return
+					return nil
 				}
 				ctx.Data["Tags"] = tags
 			}
 		}
 	}
+	return workflows
+}
+
+func prepareWorkflowList(ctx *context.Context, workflows []Workflow) {
+	actorID := ctx.FormInt64("actor")
+	status := ctx.FormInt("status")
+	workflowID := ctx.FormString("workflow")
+	page := ctx.FormInt("page")
+	if page <= 0 {
+		page = 1
+	}
 
 	// if status or actor query param is not given to frontend href, (href="/<repoLink>/actions")
 	// they will be 0 by default, which indicates get all status or actors
@@ -264,8 +312,6 @@ func List(ctx *context.Context) {
 	pager.AddParamFromRequest(ctx.Req)
 	ctx.Data["Page"] = pager
 	ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(runs) > 0
-
-	ctx.HTML(http.StatusOK, tplListActions)
 }
 
 // loadIsRefDeleted loads the IsRefDeleted field for each run in the list.
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index ba17fa427d..9a18ca5305 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -812,13 +812,8 @@ func Run(ctx *context_module.Context) {
 		return
 	}
 
-	// get workflow entry from default branch commit
-	defaultBranchCommit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
-	if err != nil {
-		ctx.Error(http.StatusInternalServerError, err.Error())
-		return
-	}
-	entries, err := actions.ListWorkflows(defaultBranchCommit)
+	// get workflow entry from runTargetCommit
+	entries, err := actions.ListWorkflows(runTargetCommit)
 	if err != nil {
 		ctx.Error(http.StatusInternalServerError, err.Error())
 		return
diff --git a/routers/web/web.go b/routers/web/web.go
index 5e0995545e..ff91bda3d2 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1412,6 +1412,7 @@ func registerRoutes(m *web.Router) {
 		m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile)
 		m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile)
 		m.Post("/run", reqRepoActionsWriter, actions.Run)
+		m.Get("/workflow-dispatch-inputs", reqRepoActionsWriter, actions.WorkflowDispatchInputs)
 
 		m.Group("/runs/{run}", func() {
 			m.Combo("").
@@ -1433,7 +1434,7 @@ func registerRoutes(m *web.Router) {
 		m.Group("/workflows/{workflow_name}", func() {
 			m.Get("/badge.svg", actions.GetWorkflowBadge)
 		})
-	}, optSignIn, context.RepoAssignment, reqRepoActionsReader, actions.MustEnableActions)
+	}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoActionsReader, actions.MustEnableActions)
 	// end "/{username}/{reponame}/actions"
 
 	m.Group("/{username}/{reponame}/wiki", func() {
diff --git a/templates/repo/actions/workflow_dispatch.tmpl b/templates/repo/actions/workflow_dispatch.tmpl
index 21f3ef2077..55fe122419 100644
--- a/templates/repo/actions/workflow_dispatch.tmpl
+++ b/templates/repo/actions/workflow_dispatch.tmpl
@@ -11,7 +11,7 @@
 					<label>{{ctx.Locale.Tr "actions.workflow.from_ref"}}:</label>
 				</span>
 				<div class="ui inline field dropdown button select-branch branch-selector-dropdown ellipsis-items-nowrap">
-					<input type="hidden" name="ref" value="refs/heads/{{index .Branches 0}}">
+					<input type="hidden" name="ref" hx-sync="this:replace" hx-target="#runWorkflowDispatchModalInputs" hx-swap="innerHTML" hx-get="{{$.Link}}/workflow-dispatch-inputs?workflow={{$.CurWorkflow}}" hx-trigger="change" value="refs/heads/{{index .Branches 0}}">
 					{{svg "octicon-git-branch" 14}}
 					<div class="default text">{{index .Branches 0}}</div>
 					{{svg "octicon-triangle-down" 14 "dropdown icon"}}
@@ -49,30 +49,9 @@
 
 			<div class="divider"></div>
 
-			{{range $item := .WorkflowDispatchConfig.Inputs}}
-			<div class="ui field {{if .Required}}required{{end}}">
-				{{if eq .Type "choice"}}
-					<label>{{.Description}}:</label>
-					<select class="ui selection type dropdown" name="{{.Name}}">
-						{{range .Options}}
-						<option value="{{.}}" {{if eq $item.Default .}}selected{{end}} >{{.}}</option>
-						{{end}}
-					</select>
-				{{else if eq .Type "boolean"}}
-					<div class="ui inline checkbox">
-						<label>{{.Description}}</label>
-						<input type="checkbox" name="{{.Name}}" {{if eq .Default "true"}}checked{{end}}>
-					</div>
-				{{else if eq .Type "number"}}
-					<label>{{.Description}}:</label>
-					<input name="{{.Name}}" value="{{.Default}}" {{if .Required}}required{{end}}>
-				{{else}}
-					<label>{{.Description}}:</label>
-					<input name="{{.Name}}" value="{{.Default}}" {{if .Required}}required{{end}}>
-				{{end}}
+			<div id="runWorkflowDispatchModalInputs">
+				{{template "repo/actions/workflow_dispatch_inputs" .}}
 			</div>
-			{{end}}
-			<button class="ui tiny primary button" type="submit">Submit</button>
 		</form>
 	</div>
 </div>
diff --git a/templates/repo/actions/workflow_dispatch_inputs.tmpl b/templates/repo/actions/workflow_dispatch_inputs.tmpl
new file mode 100644
index 0000000000..8b8292af1d
--- /dev/null
+++ b/templates/repo/actions/workflow_dispatch_inputs.tmpl
@@ -0,0 +1,45 @@
+{{if not .WorkflowDispatchConfig}}
+	<div class="ui error message tw-block">{{/* using "ui message" in "ui form" needs to force to display */}}
+		{{if not .CurWorkflowExists}}
+			{{ctx.Locale.Tr "actions.workflow.not_found" $.CurWorkflow}}
+		{{else}}
+			{{ctx.Locale.Tr "actions.workflow.has_no_workflow_dispatch" $.CurWorkflow}}
+		{{end}}
+	</div>
+{{else}}
+	{{range $item := .WorkflowDispatchConfig.Inputs}}
+		<div class="ui field {{if .Required}}required{{end}}">
+			{{if eq .Type "choice"}}
+				<label>{{or .Description .Name}}:</label>
+				{{/* htmx won't initialize the fomantic dropdown, so it is a standard "select" input */}}
+				<select class="ui selection dropdown" name="{{.Name}}">
+					{{range .Options}}
+						<option value="{{.}}" {{if eq $item.Default .}}selected{{end}}>{{.}}</option>
+					{{end}}
+				</select>
+			{{else if eq .Type "boolean"}}
+				{{/* htmx doesn't trigger our JS code to attach fomantic label to checkbox, so here we use standard checkbox */}}
+				<label class="tw-flex flex-text-inline">
+					<input type="checkbox" name="{{.Name}}" {{if eq .Default "true"}}checked{{end}}>
+					{{or .Description .Name}}
+				</label>
+			{{else if eq .Type "number"}}
+				<label>{{or .Description .Name}}:</label>
+				<input name="{{.Name}}" value="{{.Default}}" {{if .Required}}required{{end}}>
+			{{else}}
+				<label>{{or .Description .Name}}:</label>
+				<input name="{{.Name}}" value="{{.Default}}" {{if .Required}}required{{end}}>
+			{{end}}
+		</div>
+	{{end}}
+	<div class="ui field">
+		<button class="ui tiny primary button" type="submit">{{ctx.Locale.Tr "actions.workflow.run"}}</button>
+	</div>
+{{end}}
+{{range .workflows}}
+	{{if and .ErrMsg (eq .Entry.Name $.CurWorkflow)}}
+		<div class="ui field">
+			<div>{{svg "octicon-alert" 16 "text red"}} {{.ErrMsg}}</div>
+		</div>
+	{{end}}
+{{end}}