Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Support workflow event dispatch via API #32059

Merged
merged 19 commits into from
Feb 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions modules/structs/repo_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
lunny marked this conversation as resolved.
Show resolved Hide resolved
lunny marked this conversation as resolved.
Show resolved Hide resolved
}

// 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"`
}
19 changes: 19 additions & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
297 changes: 297 additions & 0 deletions routers/api/v1/repo/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package repo

import (
"errors"
"fmt"
"net/http"

actions_model "code.gitea.io/gitea/models/actions"
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Comment on lines +726 to +730
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's impossible to happen, dead code ........


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
}
Comment on lines +819 to +823
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't use locale in API.

IIRC the locale context is not properly prepared, and we never used that in code

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