From c246182112e4ee5e4f967c239250ef4e09296c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20S=C3=A1ntha?= <7604637+bencurio@users.noreply.github.com> Date: Sun, 9 Feb 2025 22:23:57 +0100 Subject: [PATCH 01/15] Feature: Support workflow event dispatch via API (#32059) ref: https://github.com/go-gitea/gitea/issues/31765 --------- Signed-off-by: Bence Santha Co-authored-by: Lunny Xiao Co-authored-by: Christopher Homberger --- modules/structs/repo_actions.go | 33 ++ routers/api/v1/api.go | 19 + routers/api/v1/repo/action.go | 297 ++++++++++ routers/api/v1/swagger/action.go | 14 + routers/api/v1/swagger/options.go | 3 + routers/web/repo/actions/view.go | 160 +----- services/actions/workflow.go | 296 ++++++++++ services/actions/workflow_interface.go | 20 + templates/swagger/v1_json.tmpl | 354 ++++++++++++ tests/integration/actions_trigger_test.go | 624 ++++++++++++++++++++++ 10 files changed, 1684 insertions(+), 136 deletions(-) create mode 100644 services/actions/workflow.go create mode 100644 services/actions/workflow_interface.go diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index b13f34473861f..109cea85c4829 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -32,3 +32,36 @@ type ActionTaskResponse struct { Entries []*ActionTask `json:"workflow_runs"` TotalCount int64 `json:"total_count"` } + +// CreateActionWorkflowDispatch represents the payload for triggering a workflow dispatch event +// swagger:model +type CreateActionWorkflowDispatch struct { + // required: true + // example: refs/heads/main + Ref string `json:"ref" binding:"Required"` + // required: false + Inputs map[string]any `json:"inputs,omitempty"` +} + +// ActionWorkflow represents a ActionWorkflow +type ActionWorkflow struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + State string `json:"state"` + // swagger:strfmt date-time + CreatedAt time.Time `json:"created_at"` + // swagger:strfmt date-time + UpdatedAt time.Time `json:"updated_at"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + BadgeURL string `json:"badge_url"` + // swagger:strfmt date-time + DeletedAt time.Time `json:"deleted_at,omitempty"` +} + +// ActionWorkflowResponse returns a ActionWorkflow +type ActionWorkflowResponse struct { + Workflows []*ActionWorkflow `json:"workflows"` + TotalCount int64 `json:"total_count"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2ffd6b129b9be..6d6e09bb8e4c8 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -915,6 +915,21 @@ func Routes() *web.Router { }) } + addActionsWorkflowRoutes := func( + m *web.Router, + actw actions.WorkflowAPI, + ) { + m.Group("/actions", func() { + m.Group("/workflows", func() { + m.Get("", reqToken(), actw.ListRepositoryWorkflows) + m.Get("/{workflow_id}", reqToken(), actw.GetWorkflow) + m.Put("/{workflow_id}/disable", reqToken(), reqRepoWriter(unit.TypeActions), actw.DisableWorkflow) + m.Post("/{workflow_id}/dispatches", reqToken(), reqRepoWriter(unit.TypeActions), bind(api.CreateActionWorkflowDispatch{}), actw.DispatchWorkflow) + m.Put("/{workflow_id}/enable", reqToken(), reqRepoWriter(unit.TypeActions), actw.EnableWorkflow) + }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeActions)) + }) + } + m.Group("", func() { // Miscellaneous (no scope required) if setting.API.EnableSwagger { @@ -1160,6 +1175,10 @@ func Routes() *web.Router { reqOwner(), repo.NewAction(), ) + addActionsWorkflowRoutes( + m, + repo.NewActionWorkflow(), + ) m.Group("/hooks/git", func() { m.Combo("").Get(repo.ListGitHooks) m.Group("/{id}", func() { diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index d27e8d2427b39..8933a10b4b116 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -5,6 +5,7 @@ package repo import ( "errors" + "fmt" "net/http" actions_model "code.gitea.io/gitea/models/actions" @@ -19,6 +20,8 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" secret_service "code.gitea.io/gitea/services/secrets" + + "github.com/nektos/act/pkg/model" ) // ListActionsSecrets list an repo's actions secrets @@ -581,3 +584,297 @@ func ListActionTasks(ctx *context.APIContext) { ctx.JSON(http.StatusOK, &res) } + +// ActionWorkflow implements actions_service.WorkflowAPI +type ActionWorkflow struct{} + +// NewActionWorkflow creates a new ActionWorkflow service +func NewActionWorkflow() actions_service.WorkflowAPI { + return ActionWorkflow{} +} + +func (a ActionWorkflow) ListRepositoryWorkflows(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/workflows repository ListRepositoryWorkflows + // --- + // summary: List repository workflows + // produces: + // - 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 + // responses: + // "200": + // "$ref": "#/responses/ActionWorkflowList" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "500": + // "$ref": "#/responses/error" + + workflows, err := actions_service.ListActionWorkflows(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListActionWorkflows", err) + return + } + + ctx.JSON(http.StatusOK, &api.ActionWorkflowResponse{Workflows: workflows, TotalCount: int64(len(workflows))}) +} + +func (a ActionWorkflow) GetWorkflow(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/workflows/{workflow_id} repository GetWorkflow + // --- + // summary: Get a workflow + // produces: + // - 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: workflow_id + // in: path + // description: id of the workflow + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionWorkflow" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "500": + // "$ref": "#/responses/error" + + workflowID := ctx.PathParam("workflow_id") + if len(workflowID) == 0 { + ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter")) + return + } + + workflow, err := actions_service.GetActionWorkflow(ctx, workflowID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetActionWorkflow", err) + return + } + + if workflow == nil { + ctx.Error(http.StatusNotFound, "GetActionWorkflow", err) + return + } + + ctx.JSON(http.StatusOK, workflow) +} + +func (a ActionWorkflow) DisableWorkflow(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable repository DisableWorkflow + // --- + // summary: Disable a workflow + // produces: + // - 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: workflow_id + // in: path + // description: id of the workflow + // type: string + // required: true + // responses: + // "204": + // description: No Content + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + workflowID := ctx.PathParam("workflow_id") + if len(workflowID) == 0 { + ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter")) + return + } + + err := actions_service.DisableActionWorkflow(ctx, workflowID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DisableActionWorkflow", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func (a ActionWorkflow) DispatchWorkflow(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches repository DispatchWorkflow + // --- + // summary: Create a workflow dispatch event + // produces: + // - 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: workflow_id + // in: path + // description: id of the workflow + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateActionWorkflowDispatch" + // responses: + // "204": + // description: No Content + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + opt := web.GetForm(ctx).(*api.CreateActionWorkflowDispatch) + + workflowID := ctx.PathParam("workflow_id") + if len(workflowID) == 0 { + ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter")) + return + } + + ref := opt.Ref + if len(ref) == 0 { + ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("ref is required parameter")) + return + } + + err := actions_service.DispatchActionWorkflow(&context.Context{ + Base: ctx.Base, + Doer: ctx.Doer, + Repo: ctx.Repo, + }, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs *map[string]any) error { + if workflowDispatch != nil { + // TODO figure out why the inputs map is empty for url form encoding workaround + if opt.Inputs == nil { + for name, config := range workflowDispatch.Inputs { + value := ctx.FormString("inputs["+name+"]", config.Default) + (*inputs)[name] = value + } + } else { + for name, config := range workflowDispatch.Inputs { + value, ok := opt.Inputs[name] + if ok { + (*inputs)[name] = value + } else { + (*inputs)[name] = config.Default + } + } + } + } + return nil + }) + if err != nil { + if terr, ok := err.(*actions_service.TranslateableError); ok { + msg := ctx.Locale.TrString(terr.Translation, terr.Args...) + ctx.Error(terr.GetCode(), msg, fmt.Errorf("%s", msg)) + return + } + ctx.Error(http.StatusInternalServerError, err.Error(), err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func (a ActionWorkflow) EnableWorkflow(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/enable repository EnableWorkflow + // --- + // summary: Enable a workflow + // produces: + // - 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: workflow_id + // in: path + // description: id of the workflow + // type: string + // required: true + // responses: + // "204": + // description: No Content + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/conflict" + // "422": + // "$ref": "#/responses/validationError" + + workflowID := ctx.PathParam("workflow_id") + if len(workflowID) == 0 { + ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter")) + return + } + + err := actions_service.EnableActionWorkflow(ctx, workflowID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "EnableActionWorkflow", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/action.go b/routers/api/v1/swagger/action.go index 665f4d0b85245..16a250184adb7 100644 --- a/routers/api/v1/swagger/action.go +++ b/routers/api/v1/swagger/action.go @@ -32,3 +32,17 @@ type swaggerResponseVariableList struct { // in:body Body []api.ActionVariable `json:"body"` } + +// ActionWorkflow +// swagger:response ActionWorkflow +type swaggerResponseActionWorkflow struct { + // in:body + Body api.ActionWorkflow `json:"body"` +} + +// ActionWorkflowList +// swagger:response ActionWorkflowList +type swaggerResponseActionWorkflowList struct { + // in:body + Body []api.ActionWorkflow `json:"body"` +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 353d6de89b755..aa5990eb38452 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -211,6 +211,9 @@ type swaggerParameterBodies struct { // in:body RenameOrgOption api.RenameOrgOption + // in:body + CreateActionWorkflowDispatch api.CreateActionWorkflowDispatch + // in:body UpdateVariableOption api.UpdateVariableOption } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index fc346b83b4736..6e09cd3de8065 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -20,8 +20,6 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" - "code.gitea.io/gitea/models/perm" - access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/actions" @@ -30,16 +28,13 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" - api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" actions_service "code.gitea.io/gitea/services/actions" context_module "code.gitea.io/gitea/services/context" - "code.gitea.io/gitea/services/convert" - "github.com/nektos/act/pkg/jobparser" "github.com/nektos/act/pkg/model" "xorm.io/builder" ) @@ -792,143 +787,36 @@ func Run(ctx *context_module.Context) { ctx.ServerError("ref", nil) return } - - // can not rerun job when workflow is disabled - cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) - cfg := cfgUnit.ActionsConfig() - if cfg.IsWorkflowDisabled(workflowID) { - ctx.Flash.Error(ctx.Tr("actions.workflow.disabled")) - ctx.Redirect(redirectURL) - return - } - - // get target commit of run from specified ref - refName := git.RefName(ref) - var runTargetCommit *git.Commit - var err error - if refName.IsTag() { - runTargetCommit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName()) - } else if refName.IsBranch() { - runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName()) - } else { - ctx.Flash.Error(ctx.Tr("form.git_ref_name_error", ref)) - ctx.Redirect(redirectURL) - return - } - if err != nil { - ctx.Flash.Error(ctx.Tr("form.target_ref_not_exist", ref)) - ctx.Redirect(redirectURL) - return - } - - // get workflow entry from runTargetCommit - entries, err := actions.ListWorkflows(runTargetCommit) - if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - - // find workflow from commit - var workflows []*jobparser.SingleWorkflow - for _, entry := range entries { - if entry.Name() == workflowID { - content, err := actions.GetContentFromEntry(entry) - if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - workflows, err = jobparser.Parse(content) - if err != nil { - ctx.ServerError("workflow", err) - return + err := actions_service.DispatchActionWorkflow(ctx, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs *map[string]any) error { + if workflowDispatch != nil { + for name, config := range workflowDispatch.Inputs { + value := ctx.Req.PostFormValue(name) + if config.Type == "boolean" { + // https://www.w3.org/TR/html401/interact/forms.html + // https://stackoverflow.com/questions/11424037/do-checkbox-inputs-only-post-data-if-theyre-checked + // Checkboxes (and radio buttons) are on/off switches that may be toggled by the user. + // A switch is "on" when the control element's checked attribute is set. + // When a form is submitted, only "on" checkbox controls can become successful. + (*inputs)[name] = strconv.FormatBool(value == "on") + } else if value != "" { + (*inputs)[name] = value + } else { + (*inputs)[name] = config.Default + } } - break } - } - - if len(workflows) == 0 { - ctx.Flash.Error(ctx.Tr("actions.workflow.not_found", workflowID)) - ctx.Redirect(redirectURL) - return - } - - // get inputs from post - workflow := &model.Workflow{ - RawOn: workflows[0].RawOn, - } - inputs := make(map[string]any) - if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil { - for name, config := range workflowDispatch.Inputs { - value := ctx.Req.PostFormValue(name) - if config.Type == "boolean" { - // https://www.w3.org/TR/html401/interact/forms.html - // https://stackoverflow.com/questions/11424037/do-checkbox-inputs-only-post-data-if-theyre-checked - // Checkboxes (and radio buttons) are on/off switches that may be toggled by the user. - // A switch is "on" when the control element's checked attribute is set. - // When a form is submitted, only "on" checkbox controls can become successful. - inputs[name] = strconv.FormatBool(value == "on") - } else if value != "" { - inputs[name] = value - } else { - inputs[name] = config.Default - } + return nil + }) + if err != nil { + if terr, ok := err.(*actions_service.TranslateableError); ok { + ctx.Flash.Error(ctx.Tr(terr.Translation, terr.Args...)) + ctx.Redirect(redirectURL) + return } - } - - // ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event - // https://docs.github.com/en/actions/learn-github-actions/contexts#github-context - // https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch - workflowDispatchPayload := &api.WorkflowDispatchPayload{ - Workflow: workflowID, - Ref: ref, - Repository: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}), - Inputs: inputs, - Sender: convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone), - } - var eventPayload []byte - if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil { - ctx.ServerError("JSONPayload", err) + ctx.ServerError(err.Error(), err) return } - run := &actions_model.ActionRun{ - Title: strings.SplitN(runTargetCommit.CommitMessage, "\n", 2)[0], - RepoID: ctx.Repo.Repository.ID, - OwnerID: ctx.Repo.Repository.OwnerID, - WorkflowID: workflowID, - TriggerUserID: ctx.Doer.ID, - Ref: ref, - CommitSHA: runTargetCommit.ID.String(), - IsForkPullRequest: false, - Event: "workflow_dispatch", - TriggerEvent: "workflow_dispatch", - EventPayload: string(eventPayload), - Status: actions_model.StatusWaiting, - } - - // cancel running jobs of the same workflow - if err := actions_model.CancelPreviousJobs( - ctx, - run.RepoID, - run.Ref, - run.WorkflowID, - run.Event, - ); err != nil { - log.Error("CancelRunningJobs: %v", err) - } - - // Insert the action run and its associated jobs into the database - if err := actions_model.InsertRun(ctx, run, workflows); err != nil { - ctx.ServerError("workflow", err) - return - } - - alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) - if err != nil { - log.Error("FindRunJobs: %v", err) - } - actions_service.CreateCommitStatus(ctx, alljobs...) - ctx.Flash.Success(ctx.Tr("actions.workflow.run_success", workflowID)) ctx.Redirect(redirectURL) } diff --git a/services/actions/workflow.go b/services/actions/workflow.go new file mode 100644 index 0000000000000..0877e62ea1873 --- /dev/null +++ b/services/actions/workflow.go @@ -0,0 +1,296 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "fmt" + "net/http" + "path" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + + "github.com/nektos/act/pkg/jobparser" + "github.com/nektos/act/pkg/model" +) + +type TranslateableError struct { + Translation string + Args []any + Code int +} + +func (t TranslateableError) Error() string { + return t.Translation +} + +func (t TranslateableError) GetCode() int { + if t.Code == 0 { + return http.StatusInternalServerError + } + return t.Code +} + +func getActionWorkflowPath(commit *git.Commit) string { + paths := []string{".gitea/workflows", ".github/workflows"} + for _, path := range paths { + if _, err := commit.SubTree(path); err == nil { + return path + } + } + return "" +} + +func getActionWorkflowEntry(ctx *context.APIContext, commit *git.Commit, folder string, entry *git.TreeEntry) *api.ActionWorkflow { + cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + + defaultBranch, _ := commit.GetBranchName() + + URL := fmt.Sprintf("%s/actions/workflows/%s", ctx.Repo.Repository.APIURL(), entry.Name()) + HTMLURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", ctx.Repo.Repository.HTMLURL(ctx), defaultBranch, folder, entry.Name()) + badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", ctx.Repo.Repository.HTMLURL(ctx), entry.Name(), ctx.Repo.Repository.DefaultBranch) + + // See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow + // State types: + // - active + // - deleted + // - disabled_fork + // - disabled_inactivity + // - disabled_manually + state := "active" + if cfg.IsWorkflowDisabled(entry.Name()) { + state = "disabled_manually" + } + + // The CreatedAt and UpdatedAt fields currently reflect the timestamp of the latest commit, which can later be refined + // by retrieving the first and last commits for the file history. The first commit would indicate the creation date, + // while the last commit would represent the modification date. The DeletedAt could be determined by identifying + // the last commit where the file existed. However, this implementation has not been done here yet, as it would likely + // cause a significant performance degradation. + createdAt := commit.Author.When + updatedAt := commit.Author.When + + return &api.ActionWorkflow{ + ID: entry.Name(), + Name: entry.Name(), + Path: path.Join(folder, entry.Name()), + State: state, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + URL: URL, + HTMLURL: HTMLURL, + BadgeURL: badgeURL, + } +} + +func disableOrEnableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error { + workflow, err := GetActionWorkflow(ctx, workflowID) + if err != nil { + return err + } + + cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + + if isEnable { + cfg.EnableWorkflow(workflow.ID) + } else { + cfg.DisableWorkflow(workflow.ID) + } + + return repo_model.UpdateRepoUnit(ctx, cfgUnit) +} + +func ListActionWorkflows(ctx *context.APIContext) ([]*api.ActionWorkflow, error) { + defaultBranchCommit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + ctx.Error(http.StatusInternalServerError, "WorkflowDefaultBranchError", err.Error()) + return nil, err + } + + entries, err := actions.ListWorkflows(defaultBranchCommit) + if err != nil { + ctx.Error(http.StatusNotFound, "WorkflowListNotFound", err.Error()) + return nil, err + } + + folder := getActionWorkflowPath(defaultBranchCommit) + + workflows := make([]*api.ActionWorkflow, len(entries)) + for i, entry := range entries { + workflows[i] = getActionWorkflowEntry(ctx, defaultBranchCommit, folder, entry) + } + + return workflows, nil +} + +func GetActionWorkflow(ctx *context.APIContext, workflowID string) (*api.ActionWorkflow, error) { + entries, err := ListActionWorkflows(ctx) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if entry.Name == workflowID { + return entry, nil + } + } + + return nil, fmt.Errorf("workflow '%s' not found", workflowID) +} + +func DisableActionWorkflow(ctx *context.APIContext, workflowID string) error { + return disableOrEnableWorkflow(ctx, workflowID, false) +} + +func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs *map[string]any) error) error { + if len(workflowID) == 0 { + return fmt.Errorf("workflowID is empty") + } + + if len(ref) == 0 { + return fmt.Errorf("ref is empty") + } + + // can not rerun job when workflow is disabled + cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + if cfg.IsWorkflowDisabled(workflowID) { + return &TranslateableError{ + Translation: "actions.workflow.disabled", + } + } + + // get target commit of run from specified ref + refName := git.RefName(ref) + var runTargetCommit *git.Commit + var err error + if refName.IsTag() { + runTargetCommit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName()) + } else if refName.IsBranch() { + runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName()) + } else { + refName = git.RefNameFromBranch(ref) + runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(ref) + } + if err != nil { + return &TranslateableError{ + Code: http.StatusNotFound, + Translation: "form.target_ref_not_exist", + Args: []any{ref}, + } + } + + // get workflow entry from runTargetCommit + entries, err := actions.ListWorkflows(runTargetCommit) + if err != nil { + return err + } + + // find workflow from commit + var workflows []*jobparser.SingleWorkflow + for _, entry := range entries { + if entry.Name() != workflowID { + continue + } + + content, err := actions.GetContentFromEntry(entry) + if err != nil { + return err + } + workflows, err = jobparser.Parse(content) + if err != nil { + return err + } + break + } + + if len(workflows) == 0 { + return &TranslateableError{ + Code: http.StatusNotFound, + Translation: "actions.workflow.not_found", + Args: []any{workflowID}, + } + } + + // get inputs from post + workflow := &model.Workflow{ + RawOn: workflows[0].RawOn, + } + inputsWithDefaults := make(map[string]any) + workflowDispatch := workflow.WorkflowDispatchConfig() + if err := processInputs(workflowDispatch, &inputsWithDefaults); err != nil { + return err + } + + // ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event + // https://docs.github.com/en/actions/learn-github-actions/contexts#github-context + // https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch + workflowDispatchPayload := &api.WorkflowDispatchPayload{ + Workflow: workflowID, + Ref: ref, + Repository: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}), + Inputs: inputsWithDefaults, + Sender: convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone), + } + var eventPayload []byte + if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil { + return fmt.Errorf("JSONPayload: %w", err) + } + + run := &actions_model.ActionRun{ + Title: strings.SplitN(runTargetCommit.CommitMessage, "\n", 2)[0], + RepoID: ctx.Repo.Repository.ID, + OwnerID: ctx.Repo.Repository.OwnerID, + WorkflowID: workflowID, + TriggerUserID: ctx.Doer.ID, + Ref: string(refName), + CommitSHA: runTargetCommit.ID.String(), + IsForkPullRequest: false, + Event: "workflow_dispatch", + TriggerEvent: "workflow_dispatch", + EventPayload: string(eventPayload), + Status: actions_model.StatusWaiting, + } + + // cancel running jobs of the same workflow + if err := actions_model.CancelPreviousJobs( + ctx, + run.RepoID, + run.Ref, + run.WorkflowID, + run.Event, + ); err != nil { + log.Error("CancelRunningJobs: %v", err) + } + + // Insert the action run and its associated jobs into the database + if err := actions_model.InsertRun(ctx, run, workflows); err != nil { + return fmt.Errorf("workflow: %w", err) + } + + alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) + if err != nil { + log.Error("FindRunJobs: %v", err) + } + CreateCommitStatus(ctx, alljobs...) + + return nil +} + +func EnableActionWorkflow(ctx *context.APIContext, workflowID string) error { + return disableOrEnableWorkflow(ctx, workflowID, true) +} diff --git a/services/actions/workflow_interface.go b/services/actions/workflow_interface.go new file mode 100644 index 0000000000000..43fa92bdf8e16 --- /dev/null +++ b/services/actions/workflow_interface.go @@ -0,0 +1,20 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import "code.gitea.io/gitea/services/context" + +// WorkflowAPI for action workflow of a repository +type WorkflowAPI interface { + // ListRepositoryWorkflows list repository workflows + ListRepositoryWorkflows(*context.APIContext) + // GetWorkflow get a workflow + GetWorkflow(*context.APIContext) + // DisableWorkflow disable a workflow + DisableWorkflow(*context.APIContext) + // DispatchWorkflow create a workflow dispatch event + DispatchWorkflow(*context.APIContext) + // EnableWorkflow enable a workflow + EnableWorkflow(*context.APIContext) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d22e01c787619..3f80d3fd9eee5 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -4421,6 +4421,275 @@ } } }, + "/repos/{owner}/{repo}/actions/workflows": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List repository workflows", + "operationId": "ListRepositoryWorkflows", + "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 + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActionWorkflowList" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "500": { + "$ref": "#/responses/error" + } + } + } + }, + "/repos/{owner}/{repo}/actions/workflows/{workflow_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get a workflow", + "operationId": "GetWorkflow", + "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": "id of the workflow", + "name": "workflow_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActionWorkflow" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "500": { + "$ref": "#/responses/error" + } + } + } + }, + "/repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Disable a workflow", + "operationId": "DisableWorkflow", + "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": "id of the workflow", + "name": "workflow_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Create a workflow dispatch event", + "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": "id of the workflow", + "name": "workflow_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateActionWorkflowDispatch" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/repos/{owner}/{repo}/actions/workflows/{workflow_id}/enable": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Enable a workflow", + "operationId": "EnableWorkflow", + "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": "id of the workflow", + "name": "workflow_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "409": { + "$ref": "#/responses/conflict" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/repos/{owner}/{repo}/activities/feeds": { "get": { "produces": [ @@ -18680,6 +18949,56 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ActionWorkflow": { + "description": "ActionWorkflow represents a ActionWorkflow", + "type": "object", + "properties": { + "badge_url": { + "type": "string", + "x-go-name": "BadgeURL" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt" + }, + "deleted_at": { + "type": "string", + "format": "date-time", + "x-go-name": "DeletedAt" + }, + "html_url": { + "type": "string", + "x-go-name": "HTMLURL" + }, + "id": { + "type": "string", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "path": { + "type": "string", + "x-go-name": "Path" + }, + "state": { + "type": "string", + "x-go-name": "State" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "UpdatedAt" + }, + "url": { + "type": "string", + "x-go-name": "URL" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Activity": { "type": "object", "properties": { @@ -19688,6 +20007,26 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "CreateActionWorkflowDispatch": { + "description": "CreateActionWorkflowDispatch represents the payload for triggering a workflow dispatch event", + "type": "object", + "required": [ + "ref" + ], + "properties": { + "inputs": { + "type": "object", + "additionalProperties": {}, + "x-go-name": "Inputs" + }, + "ref": { + "type": "string", + "x-go-name": "Ref", + "example": "refs/heads/main" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CreateBranchProtectionOption": { "description": "CreateBranchProtectionOption options for creating a branch protection", "type": "object", @@ -25687,6 +26026,21 @@ "$ref": "#/definitions/ActionVariable" } }, + "ActionWorkflow": { + "description": "ActionWorkflow", + "schema": { + "$ref": "#/definitions/ActionWorkflow" + } + }, + "ActionWorkflowList": { + "description": "ActionWorkflowList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ActionWorkflow" + } + } + }, "ActivityFeedsList": { "description": "ActivityFeedsList", "schema": { diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index 8ea9b34efe54d..e2c97662f22ec 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -5,6 +5,7 @@ package integration import ( "fmt" + "net/http" "net/url" "strings" "testing" @@ -22,6 +23,7 @@ import ( actions_module "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" @@ -651,3 +653,625 @@ func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha, targetURL, }) assert.NoError(t, err) } + +func TestWorkflowDispatchPublicApi(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // create the repo + repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{ + Name: "workflow-dispatch-event", + Description: "test workflow-dispatch ci event", + AutoInit: true, + Gitignores: "Go", + License: "MIT", + Readme: "Default", + DefaultBranch: "main", + IsPrivate: false, + }) + assert.NoError(t, err) + assert.NotEmpty(t, repo) + + // add workflow file to the repo + addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + Message: "add workflow", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + assert.NoError(t, err) + assert.NotEmpty(t, addWorkflowToBaseResp) + + // Get the commit ID of the default branch + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + assert.NoError(t, err) + defer gitRepo.Close() + branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch) + assert.NoError(t, err) + values := url.Values{} + values.Set("ref", "main") + req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), values). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNoContent) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ + Title: "add workflow", + RepoID: repo.ID, + Event: "workflow_dispatch", + Ref: "refs/heads/main", + WorkflowID: "dispatch.yml", + CommitSHA: branch.CommitID, + }) + assert.NotNil(t, run) + }) +} + +func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // create the repo + repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{ + Name: "workflow-dispatch-event", + Description: "test workflow-dispatch ci event", + AutoInit: true, + Gitignores: "Go", + License: "MIT", + Readme: "Default", + DefaultBranch: "main", + IsPrivate: false, + }) + assert.NoError(t, err) + assert.NotEmpty(t, repo) + + // add workflow file to the repo + addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + Message: "add workflow", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + assert.NoError(t, err) + assert.NotEmpty(t, addWorkflowToBaseResp) + + // Get the commit ID of the default branch + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + assert.NoError(t, err) + defer gitRepo.Close() + branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch) + assert.NoError(t, err) + values := url.Values{} + values.Set("ref", "main") + values.Set("inputs[myinput]", "val0") + values.Set("inputs[myinput3]", "true") + req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), values). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNoContent) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ + Title: "add workflow", + RepoID: repo.ID, + Event: "workflow_dispatch", + Ref: "refs/heads/main", + WorkflowID: "dispatch.yml", + CommitSHA: branch.CommitID, + }) + assert.NotNil(t, run) + dispatchPayload := &api.WorkflowDispatchPayload{} + err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload) + assert.NoError(t, err) + assert.Contains(t, dispatchPayload.Inputs, "myinput") + assert.Contains(t, dispatchPayload.Inputs, "myinput2") + assert.Contains(t, dispatchPayload.Inputs, "myinput3") + assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"]) + assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"]) + assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"]) + }) +} + +func TestWorkflowDispatchPublicApiJSON(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // create the repo + repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{ + Name: "workflow-dispatch-event", + Description: "test workflow-dispatch ci event", + AutoInit: true, + Gitignores: "Go", + License: "MIT", + Readme: "Default", + DefaultBranch: "main", + IsPrivate: false, + }) + assert.NoError(t, err) + assert.NotEmpty(t, repo) + + // add workflow file to the repo + addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + Message: "add workflow", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + assert.NoError(t, err) + assert.NotEmpty(t, addWorkflowToBaseResp) + + // Get the commit ID of the default branch + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + assert.NoError(t, err) + defer gitRepo.Close() + branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch) + assert.NoError(t, err) + inputs := &api.CreateActionWorkflowDispatch{ + Ref: "main", + Inputs: map[string]any{ + "myinput": "val0", + "myinput3": "true", + }, + } + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNoContent) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ + Title: "add workflow", + RepoID: repo.ID, + Event: "workflow_dispatch", + Ref: "refs/heads/main", + WorkflowID: "dispatch.yml", + CommitSHA: branch.CommitID, + }) + assert.NotNil(t, run) + }) +} + +func TestWorkflowDispatchPublicApiWithInputsJSON(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // create the repo + repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{ + Name: "workflow-dispatch-event", + Description: "test workflow-dispatch ci event", + AutoInit: true, + Gitignores: "Go", + License: "MIT", + Readme: "Default", + DefaultBranch: "main", + IsPrivate: false, + }) + assert.NoError(t, err) + assert.NotEmpty(t, repo) + + // add workflow file to the repo + addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + Message: "add workflow", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + assert.NoError(t, err) + assert.NotEmpty(t, addWorkflowToBaseResp) + + // Get the commit ID of the default branch + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + assert.NoError(t, err) + defer gitRepo.Close() + branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch) + assert.NoError(t, err) + inputs := &api.CreateActionWorkflowDispatch{ + Ref: "main", + Inputs: map[string]any{ + "myinput": "val0", + "myinput3": "true", + }, + } + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNoContent) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ + Title: "add workflow", + RepoID: repo.ID, + Event: "workflow_dispatch", + Ref: "refs/heads/main", + WorkflowID: "dispatch.yml", + CommitSHA: branch.CommitID, + }) + assert.NotNil(t, run) + dispatchPayload := &api.WorkflowDispatchPayload{} + err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload) + assert.NoError(t, err) + assert.Contains(t, dispatchPayload.Inputs, "myinput") + assert.Contains(t, dispatchPayload.Inputs, "myinput2") + assert.Contains(t, dispatchPayload.Inputs, "myinput3") + assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"]) + assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"]) + assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"]) + }) +} + +func TestWorkflowDispatchPublicApiWithInputsNonDefaultBranchJSON(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // create the repo + repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{ + Name: "workflow-dispatch-event", + Description: "test workflow-dispatch ci event", + AutoInit: true, + Gitignores: "Go", + License: "MIT", + Readme: "Default", + DefaultBranch: "main", + IsPrivate: false, + }) + assert.NoError(t, err) + assert.NotEmpty(t, repo) + + // add workflow file to the repo + addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + Message: "add workflow", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + assert.NoError(t, err) + assert.NotEmpty(t, addWorkflowToBaseResp) + + // add workflow file to the repo + addWorkflowToBaseResp, err = files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "update", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + Message: "add workflow", + OldBranch: "main", + NewBranch: "dispatch", + Author: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + assert.NoError(t, err) + assert.NotEmpty(t, addWorkflowToBaseResp) + + // Get the commit ID of the dispatch branch + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + assert.NoError(t, err) + defer gitRepo.Close() + commit, err := gitRepo.GetBranchCommit("dispatch") + assert.NoError(t, err) + inputs := &api.CreateActionWorkflowDispatch{ + Ref: "refs/heads/dispatch", + Inputs: map[string]any{ + "myinput": "val0", + "myinput3": "true", + }, + } + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNoContent) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ + Title: "add workflow", + RepoID: repo.ID, + Event: "workflow_dispatch", + Ref: "refs/heads/dispatch", + WorkflowID: "dispatch.yml", + CommitSHA: commit.ID.String(), + }) + assert.NotNil(t, run) + dispatchPayload := &api.WorkflowDispatchPayload{} + err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload) + assert.NoError(t, err) + assert.Contains(t, dispatchPayload.Inputs, "myinput") + assert.Contains(t, dispatchPayload.Inputs, "myinput2") + assert.Contains(t, dispatchPayload.Inputs, "myinput3") + assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"]) + assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"]) + assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"]) + }) +} + +func TestWorkflowApi(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // create the repo + repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{ + Name: "workflow-api", + Description: "test workflow apis", + AutoInit: true, + Gitignores: "Go", + License: "MIT", + Readme: "Default", + DefaultBranch: "main", + IsPrivate: false, + }) + assert.NoError(t, err) + assert.NotEmpty(t, repo) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/workflows", repo.FullName())). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + workflows := &api.ActionWorkflowResponse{} + json.NewDecoder(resp.Body).Decode(workflows) + assert.Empty(t, workflows.Workflows) + + // add workflow file to the repo + addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + Message: "add workflow", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + assert.NoError(t, err) + assert.NotEmpty(t, addWorkflowToBaseResp) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/workflows", repo.FullName())). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + json.NewDecoder(resp.Body).Decode(workflows) + assert.Len(t, workflows.Workflows, 1) + assert.Equal(t, "dispatch.yml", workflows.Workflows[0].Name) + assert.Equal(t, ".gitea/workflows/dispatch.yml", workflows.Workflows[0].Path) + assert.Equal(t, ".gitea/workflows/dispatch.yml", workflows.Workflows[0].Path) + assert.Equal(t, "active", workflows.Workflows[0].State) + + // Use a hardcoded api path + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/%s", repo.FullName(), workflows.Workflows[0].ID)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + workflow := &api.ActionWorkflow{} + json.NewDecoder(resp.Body).Decode(workflow) + assert.Equal(t, workflows.Workflows[0].ID, workflow.ID) + assert.Equal(t, workflows.Workflows[0].Path, workflow.Path) + assert.Equal(t, workflows.Workflows[0].URL, workflow.URL) + assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL) + assert.Equal(t, workflows.Workflows[0].Name, workflow.Name) + assert.Equal(t, workflows.Workflows[0].State, workflow.State) + + // Use the provided url instead of the hardcoded one + req = NewRequest(t, "GET", workflows.Workflows[0].URL). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + workflow = &api.ActionWorkflow{} + json.NewDecoder(resp.Body).Decode(workflow) + assert.Equal(t, workflows.Workflows[0].ID, workflow.ID) + assert.Equal(t, workflows.Workflows[0].Path, workflow.Path) + assert.Equal(t, workflows.Workflows[0].URL, workflow.URL) + assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL) + assert.Equal(t, workflows.Workflows[0].Name, workflow.Name) + assert.Equal(t, workflows.Workflows[0].State, workflow.State) + + // Disable the workflow + req = NewRequest(t, "PUT", workflows.Workflows[0].URL+"/disable"). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNoContent) + + // Use the provided url instead of the hardcoded one + req = NewRequest(t, "GET", workflows.Workflows[0].URL). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + workflow = &api.ActionWorkflow{} + json.NewDecoder(resp.Body).Decode(workflow) + assert.Equal(t, workflows.Workflows[0].ID, workflow.ID) + assert.Equal(t, workflows.Workflows[0].Path, workflow.Path) + assert.Equal(t, workflows.Workflows[0].URL, workflow.URL) + assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL) + assert.Equal(t, workflows.Workflows[0].Name, workflow.Name) + assert.Equal(t, "disabled_manually", workflow.State) + + inputs := &api.CreateActionWorkflowDispatch{ + Ref: "main", + Inputs: map[string]any{ + "myinput": "val0", + "myinput3": "true", + }, + } + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs). + AddTokenAuth(token) + // TODO which http code is expected here? + _ = MakeRequest(t, req, http.StatusInternalServerError) + + // Enable the workflow again + req = NewRequest(t, "PUT", workflows.Workflows[0].URL+"/enable"). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNoContent) + + // Use the provided url instead of the hardcoded one + req = NewRequest(t, "GET", workflows.Workflows[0].URL). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + workflow = &api.ActionWorkflow{} + json.NewDecoder(resp.Body).Decode(workflow) + assert.Equal(t, workflows.Workflows[0].ID, workflow.ID) + assert.Equal(t, workflows.Workflows[0].Path, workflow.Path) + assert.Equal(t, workflows.Workflows[0].URL, workflow.URL) + assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL) + assert.Equal(t, workflows.Workflows[0].Name, workflow.Name) + assert.Equal(t, workflows.Workflows[0].State, workflow.State) + + req = NewRequest(t, "GET", workflows.Workflows[0].URL). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + workflow = &api.ActionWorkflow{} + json.NewDecoder(resp.Body).Decode(workflow) + assert.Equal(t, workflows.Workflows[0].ID, workflow.ID) + assert.Equal(t, workflows.Workflows[0].Path, workflow.Path) + assert.Equal(t, workflows.Workflows[0].URL, workflow.URL) + assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL) + assert.Equal(t, workflows.Workflows[0].Name, workflow.Name) + assert.Equal(t, workflows.Workflows[0].State, workflow.State) + + // Get the commit ID of the default branch + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + assert.NoError(t, err) + defer gitRepo.Close() + branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch) + assert.NoError(t, err) + inputs = &api.CreateActionWorkflowDispatch{ + Ref: "main", + Inputs: map[string]any{ + "myinput": "val0", + "myinput3": "true", + }, + } + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNoContent) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ + Title: "add workflow", + RepoID: repo.ID, + Event: "workflow_dispatch", + Ref: "refs/heads/main", + WorkflowID: "dispatch.yml", + CommitSHA: branch.CommitID, + }) + assert.NotNil(t, run) + dispatchPayload := &api.WorkflowDispatchPayload{} + err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload) + assert.NoError(t, err) + assert.Contains(t, dispatchPayload.Inputs, "myinput") + assert.Contains(t, dispatchPayload.Inputs, "myinput2") + assert.Contains(t, dispatchPayload.Inputs, "myinput3") + assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"]) + assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"]) + assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"]) + }) +} From aa80af03f61b77b48dffb854761120141f7ce5ed Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 10 Feb 2025 18:03:33 +0800 Subject: [PATCH 02/15] remove WorkflowAPI interface because these route handlers are not reused --- routers/api/v1/api.go | 35 ++++++++------------------ routers/api/v1/repo/action.go | 28 ++++++++------------- services/actions/workflow_interface.go | 20 --------------- 3 files changed, 21 insertions(+), 62 deletions(-) delete mode 100644 services/actions/workflow_interface.go diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 6d6e09bb8e4c8..59a8c8fded2ae 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -915,21 +915,6 @@ func Routes() *web.Router { }) } - addActionsWorkflowRoutes := func( - m *web.Router, - actw actions.WorkflowAPI, - ) { - m.Group("/actions", func() { - m.Group("/workflows", func() { - m.Get("", reqToken(), actw.ListRepositoryWorkflows) - m.Get("/{workflow_id}", reqToken(), actw.GetWorkflow) - m.Put("/{workflow_id}/disable", reqToken(), reqRepoWriter(unit.TypeActions), actw.DisableWorkflow) - m.Post("/{workflow_id}/dispatches", reqToken(), reqRepoWriter(unit.TypeActions), bind(api.CreateActionWorkflowDispatch{}), actw.DispatchWorkflow) - m.Put("/{workflow_id}/enable", reqToken(), reqRepoWriter(unit.TypeActions), actw.EnableWorkflow) - }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeActions)) - }) - } - m.Group("", func() { // Miscellaneous (no scope required) if setting.API.EnableSwagger { @@ -1170,15 +1155,17 @@ func Routes() *web.Router { m.Post("/accept", repo.AcceptTransfer) m.Post("/reject", repo.RejectTransfer) }, reqToken()) - addActionsRoutes( - m, - reqOwner(), - repo.NewAction(), - ) - addActionsWorkflowRoutes( - m, - repo.NewActionWorkflow(), - ) + + addActionsRoutes(m, reqOwner(), repo.NewAction()) // it adds the routes for secrets/variables and runner management + + m.Group("/actions/workflows", func() { + m.Get("", reqToken(), repo.ActionsListRepositoryWorkflows) + m.Get("/{workflow_id}", reqToken(), repo.ActionsGetWorkflow) + m.Put("/{workflow_id}/disable", reqToken(), reqRepoWriter(unit.TypeActions), repo.ActionsDisableWorkflow) + m.Post("/{workflow_id}/dispatches", reqToken(), reqRepoWriter(unit.TypeActions), bind(api.CreateActionWorkflowDispatch{}), repo.ActionsDispatchWorkflow) + m.Put("/{workflow_id}/enable", reqToken(), reqRepoWriter(unit.TypeActions), repo.ActionsEnableWorkflow) + }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeActions)) + m.Group("/hooks/git", func() { m.Combo("").Get(repo.ListGitHooks) m.Group("/{id}", func() { diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 8933a10b4b116..f3a0b646d58c4 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -585,16 +585,8 @@ func ListActionTasks(ctx *context.APIContext) { ctx.JSON(http.StatusOK, &res) } -// ActionWorkflow implements actions_service.WorkflowAPI -type ActionWorkflow struct{} - -// NewActionWorkflow creates a new ActionWorkflow service -func NewActionWorkflow() actions_service.WorkflowAPI { - return ActionWorkflow{} -} - -func (a ActionWorkflow) ListRepositoryWorkflows(ctx *context.APIContext) { - // swagger:operation GET /repos/{owner}/{repo}/actions/workflows repository ListRepositoryWorkflows +func ActionsListRepositoryWorkflows(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/workflows repository ActionsListRepositoryWorkflows // --- // summary: List repository workflows // produces: @@ -633,8 +625,8 @@ func (a ActionWorkflow) ListRepositoryWorkflows(ctx *context.APIContext) { ctx.JSON(http.StatusOK, &api.ActionWorkflowResponse{Workflows: workflows, TotalCount: int64(len(workflows))}) } -func (a ActionWorkflow) GetWorkflow(ctx *context.APIContext) { - // swagger:operation GET /repos/{owner}/{repo}/actions/workflows/{workflow_id} repository GetWorkflow +func ActionsGetWorkflow(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/workflows/{workflow_id} repository ActionsGetWorkflow // --- // summary: Get a workflow // produces: @@ -689,8 +681,8 @@ func (a ActionWorkflow) GetWorkflow(ctx *context.APIContext) { ctx.JSON(http.StatusOK, workflow) } -func (a ActionWorkflow) DisableWorkflow(ctx *context.APIContext) { - // swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable repository DisableWorkflow +func ActionsDisableWorkflow(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable repository ActionsDisableWorkflow // --- // summary: Disable a workflow // produces: @@ -738,8 +730,8 @@ func (a ActionWorkflow) DisableWorkflow(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } -func (a ActionWorkflow) DispatchWorkflow(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches repository DispatchWorkflow +func ActionsDispatchWorkflow(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches repository ActionsDispatchWorkflow // --- // summary: Create a workflow dispatch event // produces: @@ -828,8 +820,8 @@ func (a ActionWorkflow) DispatchWorkflow(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } -func (a ActionWorkflow) EnableWorkflow(ctx *context.APIContext) { - // swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/enable repository EnableWorkflow +func ActionsEnableWorkflow(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/enable repository ActionsEnableWorkflow // --- // summary: Enable a workflow // produces: diff --git a/services/actions/workflow_interface.go b/services/actions/workflow_interface.go deleted file mode 100644 index 43fa92bdf8e16..0000000000000 --- a/services/actions/workflow_interface.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package actions - -import "code.gitea.io/gitea/services/context" - -// WorkflowAPI for action workflow of a repository -type WorkflowAPI interface { - // ListRepositoryWorkflows list repository workflows - ListRepositoryWorkflows(*context.APIContext) - // GetWorkflow get a workflow - GetWorkflow(*context.APIContext) - // DisableWorkflow disable a workflow - DisableWorkflow(*context.APIContext) - // DispatchWorkflow create a workflow dispatch event - DispatchWorkflow(*context.APIContext) - // EnableWorkflow enable a workflow - EnableWorkflow(*context.APIContext) -} From 2807d085e51d04fa0598d1ee784d3e12bbee8dbb Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 10 Feb 2025 18:40:40 +0800 Subject: [PATCH 03/15] use golang err handling approach --- modules/util/error.go | 29 ++++++++++++++ routers/api/v1/repo/action.go | 20 +++++----- routers/web/repo/actions/view.go | 21 ++++------ services/actions/workflow.go | 67 +++++++++++++------------------- 4 files changed, 75 insertions(+), 62 deletions(-) diff --git a/modules/util/error.go b/modules/util/error.go index 0f3597147ceaa..07fadf3cab7f9 100644 --- a/modules/util/error.go +++ b/modules/util/error.go @@ -36,6 +36,22 @@ func (w SilentWrap) Unwrap() error { return w.Err } +type LocaleWrap struct { + err error + TrKey string + TrArgs []any +} + +// Error returns the message +func (w LocaleWrap) Error() string { + return w.err.Error() +} + +// Unwrap returns the underlying error +func (w LocaleWrap) Unwrap() error { + return w.err +} + // NewSilentWrapErrorf returns an error that formats as the given text but unwraps as the provided error func NewSilentWrapErrorf(unwrap error, message string, args ...any) error { if len(args) == 0 { @@ -63,3 +79,16 @@ func NewAlreadyExistErrorf(message string, args ...any) error { func NewNotExistErrorf(message string, args ...any) error { return NewSilentWrapErrorf(ErrNotExist, message, args...) } + +// ErrWrapLocale wraps an err with a translation key and arguments +func ErrWrapLocale(err error, trKey string, trArgs ...any) error { + return LocaleWrap{err: err, TrKey: trKey, TrArgs: trArgs} +} + +func ErrAsLocale(err error) *LocaleWrap { + var e LocaleWrap + if errors.As(err, &e) { + return &e + } + return nil +} diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index f3a0b646d58c4..e52ed03815733 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -5,7 +5,6 @@ package repo import ( "errors" - "fmt" "net/http" actions_model "code.gitea.io/gitea/models/actions" @@ -786,21 +785,21 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { Base: ctx.Base, Doer: ctx.Doer, Repo: ctx.Repo, - }, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs *map[string]any) error { + }, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { if workflowDispatch != nil { // TODO figure out why the inputs map is empty for url form encoding workaround if opt.Inputs == nil { for name, config := range workflowDispatch.Inputs { value := ctx.FormString("inputs["+name+"]", config.Default) - (*inputs)[name] = value + inputs[name] = value } } else { for name, config := range workflowDispatch.Inputs { value, ok := opt.Inputs[name] if ok { - (*inputs)[name] = value + inputs[name] = value } else { - (*inputs)[name] = config.Default + inputs[name] = config.Default } } } @@ -808,12 +807,13 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { return nil }) if err != nil { - if terr, ok := err.(*actions_service.TranslateableError); ok { - msg := ctx.Locale.TrString(terr.Translation, terr.Args...) - ctx.Error(terr.GetCode(), msg, fmt.Errorf("%s", msg)) - return + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DispatchActionWorkflow", err) + } else if errors.Is(err, util.ErrPermissionDenied) { + ctx.Error(http.StatusForbidden, "DispatchActionWorkflow", err) + } else { + ctx.Error(http.StatusInternalServerError, "DispatchActionWorkflow", err) } - ctx.Error(http.StatusInternalServerError, err.Error(), err) return } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 6e09cd3de8065..933f6bf7bd249 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -787,33 +787,28 @@ func Run(ctx *context_module.Context) { ctx.ServerError("ref", nil) return } - err := actions_service.DispatchActionWorkflow(ctx, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs *map[string]any) error { + err := actions_service.DispatchActionWorkflow(ctx, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { if workflowDispatch != nil { for name, config := range workflowDispatch.Inputs { value := ctx.Req.PostFormValue(name) if config.Type == "boolean" { - // https://www.w3.org/TR/html401/interact/forms.html - // https://stackoverflow.com/questions/11424037/do-checkbox-inputs-only-post-data-if-theyre-checked - // Checkboxes (and radio buttons) are on/off switches that may be toggled by the user. - // A switch is "on" when the control element's checked attribute is set. - // When a form is submitted, only "on" checkbox controls can become successful. - (*inputs)[name] = strconv.FormatBool(value == "on") + inputs[name] = ctx.FormBool(name) } else if value != "" { - (*inputs)[name] = value + inputs[name] = value } else { - (*inputs)[name] = config.Default + inputs[name] = config.Default } } } return nil }) if err != nil { - if terr, ok := err.(*actions_service.TranslateableError); ok { - ctx.Flash.Error(ctx.Tr(terr.Translation, terr.Args...)) + if errLocale := util.ErrAsLocale(err); errLocale != nil { + ctx.Flash.Error(ctx.Tr(errLocale.TrKey, errLocale.TrArgs...)) ctx.Redirect(redirectURL) - return + } else { + ctx.ServerError("DispatchActionWorkflow", err) } - ctx.ServerError(err.Error(), err) return } diff --git a/services/actions/workflow.go b/services/actions/workflow.go index 0877e62ea1873..82062b98c6775 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -19,6 +19,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" @@ -26,23 +27,6 @@ import ( "github.com/nektos/act/pkg/model" ) -type TranslateableError struct { - Translation string - Args []any - Code int -} - -func (t TranslateableError) Error() string { - return t.Translation -} - -func (t TranslateableError) GetCode() int { - if t.Code == 0 { - return http.StatusInternalServerError - } - return t.Code -} - func getActionWorkflowPath(commit *git.Commit) string { paths := []string{".gitea/workflows", ".github/workflows"} for _, path := range paths { @@ -156,22 +140,29 @@ func DisableActionWorkflow(ctx *context.APIContext, workflowID string) error { return disableOrEnableWorkflow(ctx, workflowID, false) } -func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs *map[string]any) error) error { - if len(workflowID) == 0 { - return fmt.Errorf("workflowID is empty") +func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) error { + if workflowID == "" { + return util.ErrWrapLocale( + util.NewNotExistErrorf("workflowID is empty"), + "actions.workflow.not_found", workflowID, + ) } - if len(ref) == 0 { - return fmt.Errorf("ref is empty") + if ref == "" { + return util.ErrWrapLocale( + util.NewNotExistErrorf("ref is empty"), + "form.target_ref_not_exist", ref, + ) } // can not rerun job when workflow is disabled cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) cfg := cfgUnit.ActionsConfig() if cfg.IsWorkflowDisabled(workflowID) { - return &TranslateableError{ - Translation: "actions.workflow.disabled", - } + return util.ErrWrapLocale( + util.NewPermissionDeniedErrorf("workflow is disabled"), + "actions.workflow.disabled", + ) } // get target commit of run from specified ref @@ -187,11 +178,10 @@ func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, proces runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(ref) } if err != nil { - return &TranslateableError{ - Code: http.StatusNotFound, - Translation: "form.target_ref_not_exist", - Args: []any{ref}, - } + return util.ErrWrapLocale( + util.NewNotExistErrorf("ref %q doesn't exist", ref), + "form.target_ref_not_exist", ref, + ) } // get workflow entry from runTargetCommit @@ -219,11 +209,10 @@ func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, proces } if len(workflows) == 0 { - return &TranslateableError{ - Code: http.StatusNotFound, - Translation: "actions.workflow.not_found", - Args: []any{workflowID}, - } + return util.ErrWrapLocale( + util.NewNotExistErrorf("workflow %q doesn't exist", workflowID), + "actions.workflow.not_found", workflowID, + ) } // get inputs from post @@ -232,7 +221,7 @@ func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, proces } inputsWithDefaults := make(map[string]any) workflowDispatch := workflow.WorkflowDispatchConfig() - if err := processInputs(workflowDispatch, &inputsWithDefaults); err != nil { + if err := processInputs(workflowDispatch, inputsWithDefaults); err != nil { return err } @@ -279,14 +268,14 @@ func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, proces // Insert the action run and its associated jobs into the database if err := actions_model.InsertRun(ctx, run, workflows); err != nil { - return fmt.Errorf("workflow: %w", err) + return fmt.Errorf("InsertRun: %w", err) } - alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) + allJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) if err != nil { log.Error("FindRunJobs: %v", err) } - CreateCommitStatus(ctx, alljobs...) + CreateCommitStatus(ctx, allJobs...) return nil } From d5e833f04aa32849ec32435e091caf4ce647ade3 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 10 Feb 2025 18:50:33 +0800 Subject: [PATCH 04/15] remove unnecessary check --- routers/api/v1/repo/action.go | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index e52ed03815733..1964424713b27 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -661,11 +661,6 @@ func ActionsGetWorkflow(ctx *context.APIContext) { // "$ref": "#/responses/error" workflowID := ctx.PathParam("workflow_id") - if len(workflowID) == 0 { - ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter")) - return - } - workflow, err := actions_service.GetActionWorkflow(ctx, workflowID) if err != nil { ctx.Error(http.StatusInternalServerError, "GetActionWorkflow", err) @@ -715,11 +710,6 @@ func ActionsDisableWorkflow(ctx *context.APIContext) { // "$ref": "#/responses/validationError" workflowID := ctx.PathParam("workflow_id") - if len(workflowID) == 0 { - ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter")) - return - } - err := actions_service.DisableActionWorkflow(ctx, workflowID) if err != nil { ctx.Error(http.StatusInternalServerError, "DisableActionWorkflow", err) @@ -767,16 +757,9 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { // "422": // "$ref": "#/responses/validationError" - opt := web.GetForm(ctx).(*api.CreateActionWorkflowDispatch) - workflowID := ctx.PathParam("workflow_id") - if len(workflowID) == 0 { - ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter")) - return - } - - ref := opt.Ref - if len(ref) == 0 { + opt := web.GetForm(ctx).(*api.CreateActionWorkflowDispatch) + if opt.Ref == "" { ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("ref is required parameter")) return } @@ -785,7 +768,7 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { Base: ctx.Base, Doer: ctx.Doer, Repo: ctx.Repo, - }, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { + }, workflowID, opt.Ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { if workflowDispatch != nil { // TODO figure out why the inputs map is empty for url form encoding workaround if opt.Inputs == nil { @@ -857,11 +840,6 @@ func ActionsEnableWorkflow(ctx *context.APIContext) { // "$ref": "#/responses/validationError" workflowID := ctx.PathParam("workflow_id") - if len(workflowID) == 0 { - ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter")) - return - } - err := actions_service.EnableActionWorkflow(ctx, workflowID) if err != nil { ctx.Error(http.StatusInternalServerError, "EnableActionWorkflow", err) From 5b395f7c28b119400f25d25cde834650f0d3ddb9 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 10 Feb 2025 18:59:10 +0800 Subject: [PATCH 05/15] fix test and lint --- templates/swagger/v1_json.tmpl | 10 +++++----- tests/integration/actions_trigger_test.go | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 3f80d3fd9eee5..dd91b4e0a3be9 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -4430,7 +4430,7 @@ "repository" ], "summary": "List repository workflows", - "operationId": "ListRepositoryWorkflows", + "operationId": "ActionsListRepositoryWorkflows", "parameters": [ { "type": "string", @@ -4478,7 +4478,7 @@ "repository" ], "summary": "Get a workflow", - "operationId": "GetWorkflow", + "operationId": "ActionsGetWorkflow", "parameters": [ { "type": "string", @@ -4533,7 +4533,7 @@ "repository" ], "summary": "Disable a workflow", - "operationId": "DisableWorkflow", + "operationId": "ActionsDisableWorkflow", "parameters": [ { "type": "string", @@ -4585,7 +4585,7 @@ "repository" ], "summary": "Create a workflow dispatch event", - "operationId": "DispatchWorkflow", + "operationId": "ActionsDispatchWorkflow", "parameters": [ { "type": "string", @@ -4644,7 +4644,7 @@ "repository" ], "summary": "Enable a workflow", - "operationId": "EnableWorkflow", + "operationId": "ActionsEnableWorkflow", "parameters": [ { "type": "string", diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index e2c97662f22ec..b5d0c052a3a92 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -1203,10 +1203,10 @@ func TestWorkflowApi(t *testing.T) { "myinput3": "true", }, } + // Since the workflow is disabled, so the response code is 403 forbidden req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs). AddTokenAuth(token) - // TODO which http code is expected here? - _ = MakeRequest(t, req, http.StatusInternalServerError) + _ = MakeRequest(t, req, http.StatusForbidden) // Enable the workflow again req = NewRequest(t, "PUT", workflows.Workflows[0].URL+"/enable"). From 5debe79f34f06ed52c114810eebb027a8fa64150 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 10 Feb 2025 19:08:10 +0800 Subject: [PATCH 06/15] fix escaping and error handling --- routers/api/v1/repo/action.go | 23 +++++++++++++++-------- services/actions/workflow.go | 19 ++++++++++--------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 1964424713b27..51e512d8a4adf 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -663,12 +663,11 @@ func ActionsGetWorkflow(ctx *context.APIContext) { workflowID := ctx.PathParam("workflow_id") workflow, err := actions_service.GetActionWorkflow(ctx, workflowID) if err != nil { - ctx.Error(http.StatusInternalServerError, "GetActionWorkflow", err) - return - } - - if workflow == nil { - ctx.Error(http.StatusNotFound, "GetActionWorkflow", err) + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetActionWorkflow", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetActionWorkflow", err) + } return } @@ -712,7 +711,11 @@ func ActionsDisableWorkflow(ctx *context.APIContext) { workflowID := ctx.PathParam("workflow_id") err := actions_service.DisableActionWorkflow(ctx, workflowID) if err != nil { - ctx.Error(http.StatusInternalServerError, "DisableActionWorkflow", err) + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DisableActionWorkflow", err) + } else { + ctx.Error(http.StatusInternalServerError, "DisableActionWorkflow", err) + } return } @@ -842,7 +845,11 @@ func ActionsEnableWorkflow(ctx *context.APIContext) { workflowID := ctx.PathParam("workflow_id") err := actions_service.EnableActionWorkflow(ctx, workflowID) if err != nil { - ctx.Error(http.StatusInternalServerError, "EnableActionWorkflow", err) + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "EnableActionWorkflow", err) + } else { + ctx.Error(http.StatusInternalServerError, "EnableActionWorkflow", err) + } return } diff --git a/services/actions/workflow.go b/services/actions/workflow.go index 82062b98c6775..f42dda3ce5104 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -6,6 +6,7 @@ package actions import ( "fmt" "net/http" + "net/url" "path" "strings" @@ -29,9 +30,9 @@ import ( func getActionWorkflowPath(commit *git.Commit) string { paths := []string{".gitea/workflows", ".github/workflows"} - for _, path := range paths { - if _, err := commit.SubTree(path); err == nil { - return path + for _, treePath := range paths { + if _, err := commit.SubTree(treePath); err == nil { + return treePath } } return "" @@ -43,9 +44,9 @@ func getActionWorkflowEntry(ctx *context.APIContext, commit *git.Commit, folder defaultBranch, _ := commit.GetBranchName() - URL := fmt.Sprintf("%s/actions/workflows/%s", ctx.Repo.Repository.APIURL(), entry.Name()) - HTMLURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", ctx.Repo.Repository.HTMLURL(ctx), defaultBranch, folder, entry.Name()) - badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", ctx.Repo.Repository.HTMLURL(ctx), entry.Name(), ctx.Repo.Repository.DefaultBranch) + workflowURL := fmt.Sprintf("%s/actions/workflows/%s", ctx.Repo.Repository.APIURL(), url.PathEscape(entry.Name())) + workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", ctx.Repo.Repository.HTMLURL(ctx), util.PathEscapeSegments(defaultBranch), util.PathEscapeSegments(folder), url.PathEscape(entry.Name())) + badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", ctx.Repo.Repository.HTMLURL(ctx), url.PathEscape(entry.Name()), url.QueryEscape(ctx.Repo.Repository.DefaultBranch)) // See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow // State types: @@ -74,8 +75,8 @@ func getActionWorkflowEntry(ctx *context.APIContext, commit *git.Commit, folder State: state, CreatedAt: createdAt, UpdatedAt: updatedAt, - URL: URL, - HTMLURL: HTMLURL, + URL: workflowURL, + HTMLURL: workflowRepoURL, BadgeURL: badgeURL, } } @@ -133,7 +134,7 @@ func GetActionWorkflow(ctx *context.APIContext, workflowID string) (*api.ActionW } } - return nil, fmt.Errorf("workflow '%s' not found", workflowID) + return nil, util.NewNotExistErrorf("workflow %q not found", workflowID) } func DisableActionWorkflow(ctx *context.APIContext, workflowID string) error { From d6d61cdc4810377690e38b46c64716754aad93d6 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 10 Feb 2025 19:13:48 +0800 Subject: [PATCH 07/15] move reqToken to upper level --- routers/api/v1/api.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 59a8c8fded2ae..8d9e4bfd6ca53 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1159,12 +1159,12 @@ func Routes() *web.Router { addActionsRoutes(m, reqOwner(), repo.NewAction()) // it adds the routes for secrets/variables and runner management m.Group("/actions/workflows", func() { - m.Get("", reqToken(), repo.ActionsListRepositoryWorkflows) - m.Get("/{workflow_id}", reqToken(), repo.ActionsGetWorkflow) - m.Put("/{workflow_id}/disable", reqToken(), reqRepoWriter(unit.TypeActions), repo.ActionsDisableWorkflow) - m.Post("/{workflow_id}/dispatches", reqToken(), reqRepoWriter(unit.TypeActions), bind(api.CreateActionWorkflowDispatch{}), repo.ActionsDispatchWorkflow) - m.Put("/{workflow_id}/enable", reqToken(), reqRepoWriter(unit.TypeActions), repo.ActionsEnableWorkflow) - }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeActions)) + m.Get("", repo.ActionsListRepositoryWorkflows) + m.Get("/{workflow_id}", repo.ActionsGetWorkflow) + m.Put("/{workflow_id}/disable", reqRepoWriter(unit.TypeActions), repo.ActionsDisableWorkflow) + m.Put("/{workflow_id}/enable", reqRepoWriter(unit.TypeActions), repo.ActionsEnableWorkflow) + m.Post("/{workflow_id}/dispatches", reqRepoWriter(unit.TypeActions), bind(api.CreateActionWorkflowDispatch{}), repo.ActionsDispatchWorkflow) + }, context.ReferencesGitRepo(), reqToken(), reqRepoReader(unit.TypeActions)) m.Group("/hooks/git", func() { m.Combo("").Get(repo.ListGitHooks) From d404552b2282bd245036a9d058315e69514e06c7 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 10 Feb 2025 23:20:17 +0800 Subject: [PATCH 08/15] fix actions input value --- routers/api/v1/repo/action.go | 2 +- routers/web/repo/actions/view.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 51e512d8a4adf..7bcf029325f7f 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -781,7 +781,7 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { } } else { for name, config := range workflowDispatch.Inputs { - value, ok := opt.Inputs[name] + value, ok := opt.Inputs[name] // FIXME: the input value is "any", does GitHub Actions really work with "any" (eg: bool)? if ok { inputs[name] = value } else { diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 933f6bf7bd249..472c46c8d787f 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -792,7 +792,7 @@ func Run(ctx *context_module.Context) { for name, config := range workflowDispatch.Inputs { value := ctx.Req.PostFormValue(name) if config.Type == "boolean" { - inputs[name] = ctx.FormBool(name) + inputs[name] = strconv.FormatBool(ctx.FormBool(name)) } else if value != "" { inputs[name] = value } else { From 7c2a6e007bc28c4a23587dd9a9d7a32a35b31234 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 10 Feb 2025 23:22:45 +0800 Subject: [PATCH 09/15] do not process inputs if workflowDispatch is nil --- routers/api/v1/repo/action.go | 26 ++++++++++++-------------- routers/web/repo/actions/view.go | 18 ++++++++---------- services/actions/workflow.go | 7 ++++--- 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 7bcf029325f7f..e45b5e55babcf 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -772,21 +772,19 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { Doer: ctx.Doer, Repo: ctx.Repo, }, workflowID, opt.Ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { - if workflowDispatch != nil { - // TODO figure out why the inputs map is empty for url form encoding workaround - if opt.Inputs == nil { - for name, config := range workflowDispatch.Inputs { - value := ctx.FormString("inputs["+name+"]", config.Default) + // TODO figure out why the inputs map is empty for url form encoding workaround + if opt.Inputs == nil { + for name, config := range workflowDispatch.Inputs { + value := ctx.FormString("inputs["+name+"]", config.Default) + inputs[name] = value + } + } else { + for name, config := range workflowDispatch.Inputs { + value, ok := opt.Inputs[name] // FIXME: the input value is "any", does GitHub Actions really work with "any" (eg: bool)? + if ok { inputs[name] = value - } - } else { - for name, config := range workflowDispatch.Inputs { - value, ok := opt.Inputs[name] // FIXME: the input value is "any", does GitHub Actions really work with "any" (eg: bool)? - if ok { - inputs[name] = value - } else { - inputs[name] = config.Default - } + } else { + inputs[name] = config.Default } } } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 472c46c8d787f..1100471d6e40a 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -788,16 +788,14 @@ func Run(ctx *context_module.Context) { return } err := actions_service.DispatchActionWorkflow(ctx, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { - if workflowDispatch != nil { - for name, config := range workflowDispatch.Inputs { - value := ctx.Req.PostFormValue(name) - if config.Type == "boolean" { - inputs[name] = strconv.FormatBool(ctx.FormBool(name)) - } else if value != "" { - inputs[name] = value - } else { - inputs[name] = config.Default - } + for name, config := range workflowDispatch.Inputs { + value := ctx.Req.PostFormValue(name) + if config.Type == "boolean" { + inputs[name] = strconv.FormatBool(ctx.FormBool(name)) + } else if value != "" { + inputs[name] = value + } else { + inputs[name] = config.Default } } return nil diff --git a/services/actions/workflow.go b/services/actions/workflow.go index f42dda3ce5104..4d314963ac43c 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -221,9 +221,10 @@ func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, proces RawOn: workflows[0].RawOn, } inputsWithDefaults := make(map[string]any) - workflowDispatch := workflow.WorkflowDispatchConfig() - if err := processInputs(workflowDispatch, inputsWithDefaults); err != nil { - return err + if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil { + if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil { + return err + } } // ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event From 855103d9212558890ae676d2ee3d30b2b4984834 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 10 Feb 2025 23:35:04 +0800 Subject: [PATCH 10/15] use string value type for Inputs (instead of any) --- modules/structs/repo_actions.go | 2 +- tests/integration/actions_trigger_test.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 109cea85c4829..e6d11a8acb36f 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -40,7 +40,7 @@ type CreateActionWorkflowDispatch struct { // example: refs/heads/main Ref string `json:"ref" binding:"Required"` // required: false - Inputs map[string]any `json:"inputs,omitempty"` + Inputs map[string]string `json:"inputs,omitempty"` } // ActionWorkflow represents a ActionWorkflow diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index b5d0c052a3a92..17ea5c0e6950c 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -865,7 +865,7 @@ func TestWorkflowDispatchPublicApiJSON(t *testing.T) { assert.NoError(t, err) inputs := &api.CreateActionWorkflowDispatch{ Ref: "main", - Inputs: map[string]any{ + Inputs: map[string]string{ "myinput": "val0", "myinput3": "true", }, @@ -943,7 +943,7 @@ func TestWorkflowDispatchPublicApiWithInputsJSON(t *testing.T) { assert.NoError(t, err) inputs := &api.CreateActionWorkflowDispatch{ Ref: "main", - Inputs: map[string]any{ + Inputs: map[string]string{ "myinput": "val0", "myinput3": "true", }, @@ -1057,7 +1057,7 @@ func TestWorkflowDispatchPublicApiWithInputsNonDefaultBranchJSON(t *testing.T) { assert.NoError(t, err) inputs := &api.CreateActionWorkflowDispatch{ Ref: "refs/heads/dispatch", - Inputs: map[string]any{ + Inputs: map[string]string{ "myinput": "val0", "myinput3": "true", }, @@ -1198,7 +1198,7 @@ func TestWorkflowApi(t *testing.T) { inputs := &api.CreateActionWorkflowDispatch{ Ref: "main", - Inputs: map[string]any{ + Inputs: map[string]string{ "myinput": "val0", "myinput3": "true", }, @@ -1246,7 +1246,7 @@ func TestWorkflowApi(t *testing.T) { assert.NoError(t, err) inputs = &api.CreateActionWorkflowDispatch{ Ref: "main", - Inputs: map[string]any{ + Inputs: map[string]string{ "myinput": "val0", "myinput3": "true", }, From 1283ac44527c3f1466c962a14966a1b7ab90dfbc Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 10 Feb 2025 23:37:04 +0800 Subject: [PATCH 11/15] use multiple line string to make tests easier to read --- tests/integration/actions_trigger_test.go | 159 +++++++++++++++++----- 1 file changed, 126 insertions(+), 33 deletions(-) diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index 17ea5c0e6950c..096f51dfc0575 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -74,9 +74,19 @@ func TestPullRequestTargetEvent(t *testing.T) { addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user2, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { - Operation: "create", - TreePath: ".gitea/workflows/pr.yml", - ContentReader: strings.NewReader("name: test\non:\n pull_request_target:\n paths:\n - 'file_*.txt'\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + Operation: "create", + TreePath: ".gitea/workflows/pr.yml", + ContentReader: strings.NewReader(`name: test +on: + pull_request_target: + paths: + - 'file_*.txt' +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), }, }, Message: "add workflow", @@ -230,9 +240,19 @@ func TestSkipCI(t *testing.T) { addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { - Operation: "create", - TreePath: ".gitea/workflows/pr.yml", - ContentReader: strings.NewReader("name: test\non:\n push:\n branches: [master]\n pull_request:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + Operation: "create", + TreePath: ".gitea/workflows/pr.yml", + ContentReader: strings.NewReader(`name: test +on: + push: + branches: [master] + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), }, }, Message: "add workflow", @@ -349,9 +369,17 @@ func TestCreateDeleteRefEvent(t *testing.T) { addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { - Operation: "create", - TreePath: ".gitea/workflows/createdelete.yml", - ContentReader: strings.NewReader("name: test\non:\n [create,delete]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + Operation: "create", + TreePath: ".gitea/workflows/createdelete.yml", + ContentReader: strings.NewReader(`name: test +on: + [create,delete] +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), }, }, Message: "add workflow", @@ -463,9 +491,18 @@ func TestPullRequestCommitStatusEvent(t *testing.T) { addWorkflow, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { - Operation: "create", - TreePath: ".gitea/workflows/pr.yml", - ContentReader: strings.NewReader("name: test\non:\n pull_request:\n types: [assigned, unassigned, labeled, unlabeled, opened, edited, closed, reopened, synchronize, milestoned, demilestoned, review_requested, review_request_removed]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + Operation: "create", + TreePath: ".gitea/workflows/pr.yml", + ContentReader: strings.NewReader(`name: test +on: + pull_request: + types: [assigned, unassigned, labeled, unlabeled, opened, edited, closed, reopened, synchronize, milestoned, demilestoned, review_requested, review_request_removed] +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), }, }, Message: "add workflow", @@ -678,9 +715,17 @@ func TestWorkflowDispatchPublicApi(t *testing.T) { addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { - Operation: "create", - TreePath: ".gitea/workflows/dispatch.yml", - ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader(`name: test +on: + workflow_dispatch +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), }, }, Message: "add workflow", @@ -750,9 +795,17 @@ func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) { addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { - Operation: "create", - TreePath: ".gitea/workflows/dispatch.yml", - ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader(`name: test +on: + workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } } +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), }, }, Message: "add workflow", @@ -833,9 +886,17 @@ func TestWorkflowDispatchPublicApiJSON(t *testing.T) { addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { - Operation: "create", - TreePath: ".gitea/workflows/dispatch.yml", - ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader(`name: test +on: + workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } } +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), }, }, Message: "add workflow", @@ -911,9 +972,17 @@ func TestWorkflowDispatchPublicApiWithInputsJSON(t *testing.T) { addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { - Operation: "create", - TreePath: ".gitea/workflows/dispatch.yml", - ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader(`name: test +on: + workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } } +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), }, }, Message: "add workflow", @@ -997,9 +1066,17 @@ func TestWorkflowDispatchPublicApiWithInputsNonDefaultBranchJSON(t *testing.T) { addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { - Operation: "create", - TreePath: ".gitea/workflows/dispatch.yml", - ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader(`name: test +on: + workflow_dispatch +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), }, }, Message: "add workflow", @@ -1025,9 +1102,17 @@ func TestWorkflowDispatchPublicApiWithInputsNonDefaultBranchJSON(t *testing.T) { addWorkflowToBaseResp, err = files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { - Operation: "update", - TreePath: ".gitea/workflows/dispatch.yml", - ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + Operation: "update", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader(`name: test +on: + workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } } +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), }, }, Message: "add workflow", @@ -1118,9 +1203,17 @@ func TestWorkflowApi(t *testing.T) { addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { - Operation: "create", - TreePath: ".gitea/workflows/dispatch.yml", - ContentReader: strings.NewReader("name: test\non:\n workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader(`name: test +on: + workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } } +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), }, }, Message: "add workflow", From 7853bc970b96548607bb0f3ac01b080ad4108224 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 11 Feb 2025 00:00:19 +0800 Subject: [PATCH 12/15] fix comments (chi's Binding can't parse form map values) --- routers/api/v1/repo/action.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index e45b5e55babcf..7763e416d2167 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -6,6 +6,7 @@ package repo import ( "errors" "net/http" + "strings" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" @@ -772,15 +773,16 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { Doer: ctx.Doer, Repo: ctx.Repo, }, workflowID, opt.Ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { - // TODO figure out why the inputs map is empty for url form encoding workaround - if opt.Inputs == nil { + if strings.Contains(ctx.Req.Header.Get("Content-Type"), "form-urlencoded") { + // The chi framework's "Binding" doesn't support to bind the form map values into a map[string]string + // So we have to manually read the `inputs[key]` from the form for name, config := range workflowDispatch.Inputs { value := ctx.FormString("inputs["+name+"]", config.Default) inputs[name] = value } } else { for name, config := range workflowDispatch.Inputs { - value, ok := opt.Inputs[name] // FIXME: the input value is "any", does GitHub Actions really work with "any" (eg: bool)? + value, ok := opt.Inputs[name] if ok { inputs[name] = value } else { @@ -790,6 +792,7 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { } return nil }) + if err != nil { if errors.Is(err, util.ErrNotExist) { ctx.Error(http.StatusNotFound, "DispatchActionWorkflow", err) From 8615c681d94d886ff9e53c37dead46d3be2ef934 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 11 Feb 2025 00:09:18 +0800 Subject: [PATCH 13/15] decouple Context (it should never manually construct any web/api Context) --- routers/api/v1/repo/action.go | 10 +++------- routers/web/repo/actions/view.go | 2 +- services/actions/workflow.go | 32 +++++++++++++------------------- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 7763e416d2167..8253f628591d3 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -710,7 +710,7 @@ func ActionsDisableWorkflow(ctx *context.APIContext) { // "$ref": "#/responses/validationError" workflowID := ctx.PathParam("workflow_id") - err := actions_service.DisableActionWorkflow(ctx, workflowID) + err := actions_service.EnableOrDisableWorkflow(ctx, workflowID, false) if err != nil { if errors.Is(err, util.ErrNotExist) { ctx.Error(http.StatusNotFound, "DisableActionWorkflow", err) @@ -768,11 +768,7 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { return } - err := actions_service.DispatchActionWorkflow(&context.Context{ - Base: ctx.Base, - Doer: ctx.Doer, - Repo: ctx.Repo, - }, workflowID, opt.Ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { + err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, opt.Ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { if strings.Contains(ctx.Req.Header.Get("Content-Type"), "form-urlencoded") { // The chi framework's "Binding" doesn't support to bind the form map values into a map[string]string // So we have to manually read the `inputs[key]` from the form @@ -844,7 +840,7 @@ func ActionsEnableWorkflow(ctx *context.APIContext) { // "$ref": "#/responses/validationError" workflowID := ctx.PathParam("workflow_id") - err := actions_service.EnableActionWorkflow(ctx, workflowID) + err := actions_service.EnableOrDisableWorkflow(ctx, workflowID, true) if err != nil { if errors.Is(err, util.ErrNotExist) { ctx.Error(http.StatusNotFound, "EnableActionWorkflow", err) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 1100471d6e40a..7099582c1b89d 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -787,7 +787,7 @@ func Run(ctx *context_module.Context) { ctx.ServerError("ref", nil) return } - err := actions_service.DispatchActionWorkflow(ctx, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { + err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { for name, config := range workflowDispatch.Inputs { value := ctx.Req.PostFormValue(name) if config.Type == "boolean" { diff --git a/services/actions/workflow.go b/services/actions/workflow.go index 4d314963ac43c..4470b60c64091 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -16,9 +16,11 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/reqctx" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" @@ -81,7 +83,7 @@ func getActionWorkflowEntry(ctx *context.APIContext, commit *git.Commit, folder } } -func disableOrEnableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error { +func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error { workflow, err := GetActionWorkflow(ctx, workflowID) if err != nil { return err @@ -137,11 +139,7 @@ func GetActionWorkflow(ctx *context.APIContext, workflowID string) (*api.ActionW return nil, util.NewNotExistErrorf("workflow %q not found", workflowID) } -func DisableActionWorkflow(ctx *context.APIContext, workflowID string) error { - return disableOrEnableWorkflow(ctx, workflowID, false) -} - -func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) error { +func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) error { if workflowID == "" { return util.ErrWrapLocale( util.NewNotExistErrorf("workflowID is empty"), @@ -157,7 +155,7 @@ func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, proces } // can not rerun job when workflow is disabled - cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) + cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) cfg := cfgUnit.ActionsConfig() if cfg.IsWorkflowDisabled(workflowID) { return util.ErrWrapLocale( @@ -171,12 +169,12 @@ func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, proces var runTargetCommit *git.Commit var err error if refName.IsTag() { - runTargetCommit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName()) + runTargetCommit, err = gitRepo.GetTagCommit(refName.TagName()) } else if refName.IsBranch() { - runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName()) + runTargetCommit, err = gitRepo.GetBranchCommit(refName.BranchName()) } else { refName = git.RefNameFromBranch(ref) - runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(ref) + runTargetCommit, err = gitRepo.GetBranchCommit(ref) } if err != nil { return util.ErrWrapLocale( @@ -233,9 +231,9 @@ func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, proces workflowDispatchPayload := &api.WorkflowDispatchPayload{ Workflow: workflowID, Ref: ref, - Repository: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}), + Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}), Inputs: inputsWithDefaults, - Sender: convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone), + Sender: convert.ToUserWithAccessMode(ctx, doer, perm.AccessModeNone), } var eventPayload []byte if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil { @@ -244,10 +242,10 @@ func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, proces run := &actions_model.ActionRun{ Title: strings.SplitN(runTargetCommit.CommitMessage, "\n", 2)[0], - RepoID: ctx.Repo.Repository.ID, - OwnerID: ctx.Repo.Repository.OwnerID, + RepoID: repo.ID, + OwnerID: repo.OwnerID, WorkflowID: workflowID, - TriggerUserID: ctx.Doer.ID, + TriggerUserID: doer.ID, Ref: string(refName), CommitSHA: runTargetCommit.ID.String(), IsForkPullRequest: false, @@ -281,7 +279,3 @@ func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, proces return nil } - -func EnableActionWorkflow(ctx *context.APIContext, workflowID string) error { - return disableOrEnableWorkflow(ctx, workflowID, true) -} From e884c8abb689ca06970883916586454621ecec62 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 11 Feb 2025 00:15:00 +0800 Subject: [PATCH 14/15] add more comments --- services/context/api.go | 3 +++ services/context/base.go | 4 ++++ services/context/context.go | 5 ++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/services/context/api.go b/services/context/api.go index bdeff0af63480..baf4131edc1f5 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -22,6 +22,9 @@ import ( ) // APIContext is a specific context for API service +// ATTENTION: This struct should never be manually constructed in routes/services, +// it has many internal details which should be carefully prepared by the framework. +// If it is abused, it would cause strange bugs like panic/resource-leak. type APIContext struct { *Base diff --git a/services/context/base.go b/services/context/base.go index 5db84f42a55f0..4d1c3659a2646 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -23,6 +23,10 @@ type BaseContextKeyType struct{} var BaseContextKey BaseContextKeyType +// Base is the base context for all web handlers +// ATTENTION: This struct should never be manually constructed in routes/services, +// it has many internal details which should be carefully prepared by the framework. +// If it is abused, it would cause strange bugs like panic/resource-leak. type Base struct { reqctx.RequestContext diff --git a/services/context/context.go b/services/context/context.go index 5b16f9be98298..7aeb0de7ab46b 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -34,7 +34,10 @@ type Render interface { HTML(w io.Writer, status int, name templates.TplName, data any, templateCtx context.Context) error } -// Context represents context of a request. +// Context represents context of a web request. +// ATTENTION: This struct should never be manually constructed in routes/services, +// it has many internal details which should be carefully prepared by the framework. +// If it is abused, it would cause strange bugs like panic/resource-leak. type Context struct { *Base From 54ffed25e07ad35cc21502c08fc1afbc3232ef7a Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 11 Feb 2025 00:16:49 +0800 Subject: [PATCH 15/15] fix swagger doc and lint --- routers/api/v1/repo/action.go | 1 - templates/swagger/v1_json.tmpl | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 8253f628591d3..1f5f211a2c744 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -788,7 +788,6 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { } return nil }) - if err != nil { if errors.Is(err, util.ErrNotExist) { ctx.Error(http.StatusNotFound, "DispatchActionWorkflow", err) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index dd91b4e0a3be9..80cf1b5623d67 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -20016,7 +20016,9 @@ "properties": { "inputs": { "type": "object", - "additionalProperties": {}, + "additionalProperties": { + "type": "string" + }, "x-go-name": "Inputs" }, "ref": {