From 4c6de3ad2e7bee6cd8309789fdc503acd0dc25b9 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Sun, 18 Jan 2026 11:50:02 +0100 Subject: [PATCH] feat: add actions workflow enable and disable --- cmd/actions.go | 169 +++++++++++++++++++++++++++++++++-------- go.mod | 2 + go.sum | 2 + internal/api/client.go | 25 ++++-- 4 files changed, 159 insertions(+), 39 deletions(-) diff --git a/cmd/actions.go b/cmd/actions.go index 1e48823..f9d4685 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -2,8 +2,10 @@ package cmd import ( "fmt" + "net/http" "os" "strconv" + "strings" "text/tabwriter" "time" @@ -163,6 +165,22 @@ var workflowRunCmd = &cobra.Command{ RunE: runWorkflowRun, } +var workflowEnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable a workflow", + Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowEnable, +} + +var workflowDisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable a workflow", + Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowDisable, +} + // Secret commands var actionsSecretCmd = &cobra.Command{ Use: "secret", @@ -255,6 +273,8 @@ func init() { workflowCmd.AddCommand(workflowListCmd) workflowCmd.AddCommand(workflowViewCmd) workflowCmd.AddCommand(workflowRunCmd) + workflowCmd.AddCommand(workflowEnableCmd) + workflowCmd.AddCommand(workflowDisableCmd) // Add secret commands actionsCmd.AddCommand(actionsSecretCmd) @@ -292,6 +312,8 @@ func init() { addRepoFlags(workflowViewCmd) workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON") addRepoFlags(workflowRunCmd) + addRepoFlags(workflowEnableCmd) + addRepoFlags(workflowDisableCmd) workflowRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)") workflowRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)") workflowRunCmd.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)") @@ -874,37 +896,9 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { } workflowIdentifier := args[0] - - // Find the workflow by listing from both .gitea/workflows and .forgejo/workflows - var workflow *Workflow - - for _, dir := range []string{".gitea/workflows", ".forgejo/workflows"} { - endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, name, dir) - - var contents []ContentsResponse - if err := client.GetJSON(endpoint, &contents); err != nil { - // Directory might not exist, continue - continue - } - - for _, content := range contents { - if content.Type == "file" && (content.Name == workflowIdentifier || content.Path == workflowIdentifier) { - workflow = &Workflow{ - Name: content.Name, - Path: content.Path, - State: "active", - } - break - } - } - - if workflow != nil { - break - } - } - - if workflow == nil { - return fmt.Errorf("workflow '%s' not found", workflowIdentifier) + workflow, err := findWorkflow(client, owner, name, workflowIdentifier) + if err != nil { + return err } jsonOutput, _ := cmd.Flags().GetBool("json") @@ -1024,6 +1018,96 @@ func runWorkflowRun(cmd *cobra.Command, args []string) error { return nil } +func runWorkflowEnable(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + workflowIdentifier := args[0] + workflow, err := findWorkflow(client, owner, name, workflowIdentifier) + if err != nil { + return err + } + + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/enable", owner, name, workflow.Name) + + // Try PUT first (correct method per GitHub/Gitea API spec) + status, err := client.DoJSON(http.MethodPut, endpoint, nil, nil) + if err != nil && (status == http.StatusNotFound || status == http.StatusMethodNotAllowed) { + // Fall back to POST for older versions + status, err = client.DoJSON(http.MethodPost, endpoint, nil, nil) + } + + if err != nil { + if status == http.StatusNotFound && strings.Contains(err.Error(), "404") { + return fmt.Errorf("failed to enable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+\n" + + "Your instance does not support the workflow enable/disable API endpoints yet.\n" + + "You can enable workflows via the web UI instead.") + } + return fmt.Errorf("failed to enable workflow: %w", err) + } + + fmt.Printf("✓ Workflow '%s' enabled\n", workflow.Name) + return nil +} + +func runWorkflowDisable(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + workflowIdentifier := args[0] + workflow, err := findWorkflow(client, owner, name, workflowIdentifier) + if err != nil { + return err + } + + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/disable", owner, name, workflow.Name) + + // Try PUT first (correct method per GitHub/Gitea API spec) + status, err := client.DoJSON(http.MethodPut, endpoint, nil, nil) + if err != nil && (status == http.StatusNotFound || status == http.StatusMethodNotAllowed) { + // Fall back to POST for older versions + status, err = client.DoJSON(http.MethodPost, endpoint, nil, nil) + } + + if err != nil { + if status == http.StatusNotFound && strings.Contains(err.Error(), "404") { + return fmt.Errorf("failed to disable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+\n" + + "Your instance does not support the workflow enable/disable API endpoints yet.\n" + + "You can disable workflows via the web UI instead.") + } + return fmt.Errorf("failed to disable workflow: %w", err) + } + + fmt.Printf("✓ Workflow '%s' disabled\n", workflow.Name) + return nil +} + func splitKeyValue(s string) []string { idx := -1 for i, c := range s { @@ -1038,6 +1122,29 @@ func splitKeyValue(s string) []string { return []string{s[:idx], s[idx+1:]} } +func findWorkflow(client *api.Client, owner, name, workflowIdentifier string) (*Workflow, error) { + for _, dir := range []string{".gitea/workflows", ".forgejo/workflows"} { + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, name, dir) + + var contents []ContentsResponse + if err := client.GetJSON(endpoint, &contents); err != nil { + continue + } + + for _, content := range contents { + if content.Type == "file" && (content.Name == workflowIdentifier || content.Path == workflowIdentifier) { + return &Workflow{ + Name: content.Name, + Path: content.Path, + State: "active", + }, nil + } + } + } + + return nil, fmt.Errorf("workflow '%s' not found", workflowIdentifier) +} + // Secret command implementations func runActionsSecretList(cmd *cobra.Command, args []string) error { diff --git a/go.mod b/go.mod index 66a6f73..5c70717 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( require ( github.com/42wim/httpsig v1.2.3 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect @@ -21,6 +22,7 @@ require ( github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/go.sum b/go.sum index a28f421..f344c22 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,6 +39,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= diff --git a/internal/api/client.go b/internal/api/client.go index 93904c9..083499c 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -88,6 +88,13 @@ func (c *Client) GetJSON(path string, result any) error { // PostJSON performs a POST request to the specified path with JSON body func (c *Client) PostJSON(path string, body any, result any) error { + _, err := c.DoJSON(http.MethodPost, path, body, result) + return err +} + +// DoJSON performs an HTTP request with a JSON body and decodes the JSON response. +// Returns the HTTP status code and any error encountered. +func (c *Client) DoJSON(method string, path string, body any, result any) (int, error) { baseURL := "https://" + c.hostname url := baseURL + path @@ -95,14 +102,14 @@ func (c *Client) PostJSON(path string, body any, result any) error { if body != nil { bodyBytes, err := json.Marshal(body) if err != nil { - return fmt.Errorf("failed to marshal request body: %w", err) + return 0, fmt.Errorf("failed to marshal request body: %w", err) } bodyReader = bytes.NewReader(bodyBytes) } - req, err := http.NewRequest(http.MethodPost, url, bodyReader) + req, err := http.NewRequest(method, url, bodyReader) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return 0, fmt.Errorf("failed to create request: %w", err) } // Set authentication header @@ -110,12 +117,14 @@ func (c *Client) PostJSON(path string, body any, result any) error { req.Header.Set("Authorization", "token "+c.token) } req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } httpClient := &http.Client{} resp, err := httpClient.Do(req) if err != nil { - return fmt.Errorf("failed to perform request: %w", err) + return 0, fmt.Errorf("failed to perform request: %w", err) } defer func() { if closeErr := resp.Body.Close(); closeErr != nil && err == nil { @@ -125,16 +134,16 @@ func (c *Client) PostJSON(path string, body any, result any) error { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + return resp.StatusCode, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } if result != nil && resp.StatusCode != http.StatusNoContent { if err := json.NewDecoder(resp.Body).Decode(result); err != nil { - return fmt.Errorf("failed to decode response: %w", err) + return resp.StatusCode, fmt.Errorf("failed to decode response: %w", err) } } - return nil + return resp.StatusCode, nil } // GetRawLog performs a GET request and returns the raw response body as string