feat: add actions workflow enable and disable

This commit is contained in:
Romain Bertrand 2026-01-18 11:50:02 +01:00
parent c2ee338f1c
commit 4c6de3ad2e
4 changed files with 159 additions and 39 deletions

View file

@ -2,8 +2,10 @@ package cmd
import ( import (
"fmt" "fmt"
"net/http"
"os" "os"
"strconv" "strconv"
"strings"
"text/tabwriter" "text/tabwriter"
"time" "time"
@ -163,6 +165,22 @@ var workflowRunCmd = &cobra.Command{
RunE: runWorkflowRun, RunE: runWorkflowRun,
} }
var workflowEnableCmd = &cobra.Command{
Use: "enable <workflow>",
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 <workflow>",
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 // Secret commands
var actionsSecretCmd = &cobra.Command{ var actionsSecretCmd = &cobra.Command{
Use: "secret", Use: "secret",
@ -255,6 +273,8 @@ func init() {
workflowCmd.AddCommand(workflowListCmd) workflowCmd.AddCommand(workflowListCmd)
workflowCmd.AddCommand(workflowViewCmd) workflowCmd.AddCommand(workflowViewCmd)
workflowCmd.AddCommand(workflowRunCmd) workflowCmd.AddCommand(workflowRunCmd)
workflowCmd.AddCommand(workflowEnableCmd)
workflowCmd.AddCommand(workflowDisableCmd)
// Add secret commands // Add secret commands
actionsCmd.AddCommand(actionsSecretCmd) actionsCmd.AddCommand(actionsSecretCmd)
@ -292,6 +312,8 @@ func init() {
addRepoFlags(workflowViewCmd) addRepoFlags(workflowViewCmd)
workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON") workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON")
addRepoFlags(workflowRunCmd) 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().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("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)") 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] workflowIdentifier := args[0]
workflow, err := findWorkflow(client, owner, name, workflowIdentifier)
// Find the workflow by listing from both .gitea/workflows and .forgejo/workflows if err != nil {
var workflow *Workflow return err
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)
} }
jsonOutput, _ := cmd.Flags().GetBool("json") jsonOutput, _ := cmd.Flags().GetBool("json")
@ -1024,6 +1018,96 @@ func runWorkflowRun(cmd *cobra.Command, args []string) error {
return nil 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 { func splitKeyValue(s string) []string {
idx := -1 idx := -1
for i, c := range s { for i, c := range s {
@ -1038,6 +1122,29 @@ func splitKeyValue(s string) []string {
return []string{s[:idx], s[idx+1:]} 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 // Secret command implementations
func runActionsSecretList(cmd *cobra.Command, args []string) error { func runActionsSecretList(cmd *cobra.Command, args []string) error {

2
go.mod
View file

@ -12,6 +12,7 @@ require (
require ( require (
github.com/42wim/httpsig v1.2.3 // indirect 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/davidmz/go-pageant v1.0.2 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-fed/httpsig v1.1.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/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // 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/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect

2
go.sum
View file

@ -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= 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 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= 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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/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/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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/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 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=

View file

@ -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 // PostJSON performs a POST request to the specified path with JSON body
func (c *Client) PostJSON(path string, body any, result any) error { 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 baseURL := "https://" + c.hostname
url := baseURL + path url := baseURL + path
@ -95,14 +102,14 @@ func (c *Client) PostJSON(path string, body any, result any) error {
if body != nil { if body != nil {
bodyBytes, err := json.Marshal(body) bodyBytes, err := json.Marshal(body)
if err != nil { 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) bodyReader = bytes.NewReader(bodyBytes)
} }
req, err := http.NewRequest(http.MethodPost, url, bodyReader) req, err := http.NewRequest(method, url, bodyReader)
if err != nil { 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 // 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("Authorization", "token "+c.token)
} }
req.Header.Set("Accept", "application/json") 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{} httpClient := &http.Client{}
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to perform request: %w", err) return 0, fmt.Errorf("failed to perform request: %w", err)
} }
defer func() { defer func() {
if closeErr := resp.Body.Close(); closeErr != nil && err == nil { 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 { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
bodyBytes, _ := io.ReadAll(resp.Body) 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 result != nil && resp.StatusCode != http.StatusNoContent {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil { 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 // GetRawLog performs a GET request and returns the raw response body as string