Merge pull request 'feat: implement workflow list/view/run' (#26) from feat/workflows into main

Reviewed-on: https://codeberg.org/romaintb/fgj/pulls/26
This commit is contained in:
Romain Bertrand 2026-01-16 11:31:37 +01:00
commit 937aa09a2c
4 changed files with 518 additions and 0 deletions

View file

@ -182,6 +182,21 @@ fgj release delete v1.2.3
### Forgejo Actions
```bash
# List workflows
fgj actions workflow list
# View a workflow
fgj actions workflow view ci.yml
# Run a workflow (trigger workflow_dispatch)
fgj actions workflow run deploy.yml
# Run a workflow with inputs
fgj actions workflow run deploy.yml -f environment=production -f version=1.2.3
# Run a workflow on a specific branch
fgj actions workflow run deploy.yml -r feature-branch
# List workflow runs
fgj actions run list
@ -324,6 +339,22 @@ fgj/
Contributions are welcome! Please feel free to submit a Pull Request.
## Missing Features / Roadmap
`fgj` aims to be a drop-in replacement for `gh` when working with Forgejo instances. While we've implemented the core features, some `gh` commands are not yet available:
### Forgejo Actions / Workflows
**Not Yet Implemented:**
- `workflow enable/disable` - Enable or disable workflows
- `run watch` - Follow a workflow run in real-time
- `run rerun` - Rerun entire run, failed jobs, or specific jobs
- `run cancel` - Cancel a running workflow
- `run delete` - Delete a workflow run
- `run download` - Download workflow run artifacts
We welcome contributions to implement any of these features! Please check the issues or create a new one to discuss implementation before starting work.
## License
MIT License

View file

@ -58,6 +58,28 @@ type ActionTaskList struct {
TotalCount int `json:"total_count"`
}
// Workflow represents a workflow definition
type Workflow struct {
ID int64 `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
State string `json:"state"`
}
// WorkflowList represents a list of workflows
type WorkflowList struct {
Workflows []Workflow `json:"workflows"`
TotalCount int `json:"total_count"`
}
// ContentsResponse represents a file/directory in the repository
type ContentsResponse struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
}
var actionsCmd = &cobra.Command{
Use: "actions",
Aliases: []string{"action"},
@ -87,6 +109,36 @@ var runViewCmd = &cobra.Command{
RunE: runRunView,
}
// Workflow commands
var workflowCmd = &cobra.Command{
Use: "workflow",
Short: "Manage workflows",
Long: "List, view, and run workflows.",
}
var workflowListCmd = &cobra.Command{
Use: "list",
Short: "List workflows",
Long: "List all workflows in a repository.",
RunE: runWorkflowList,
}
var workflowViewCmd = &cobra.Command{
Use: "view <workflow>",
Short: "View a workflow",
Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
Args: cobra.ExactArgs(1),
RunE: runWorkflowView,
}
var workflowRunCmd = &cobra.Command{
Use: "run <workflow>",
Short: "Run a workflow",
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
Args: cobra.ExactArgs(1),
RunE: runWorkflowRun,
}
// Secret commands
var actionsSecretCmd = &cobra.Command{
Use: "secret",
@ -171,6 +223,12 @@ func init() {
runCmd.AddCommand(runListCmd)
runCmd.AddCommand(runViewCmd)
// Add workflow commands (gh workflow compatible)
actionsCmd.AddCommand(workflowCmd)
workflowCmd.AddCommand(workflowListCmd)
workflowCmd.AddCommand(workflowViewCmd)
workflowCmd.AddCommand(workflowRunCmd)
// Add secret commands
actionsCmd.AddCommand(actionsSecretCmd)
actionsSecretCmd.AddCommand(actionsSecretListCmd)
@ -194,6 +252,15 @@ func init() {
runViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run")
runViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job")
// Add flags for workflow commands
addRepoFlags(workflowListCmd)
workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
addRepoFlags(workflowViewCmd)
addRepoFlags(workflowRunCmd)
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)")
// Add flags for secret commands
addRepoFlags(actionsSecretListCmd)
addRepoFlags(actionsSecretCreateCmd)
@ -499,6 +566,252 @@ func formatTimeSince(t time.Time) string {
return fmt.Sprintf("%d days ago", days)
}
// Workflow command implementations
func runWorkflowList(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
}
limit, _ := cmd.Flags().GetInt("limit")
// List workflows from both .gitea/workflows and .forgejo/workflows
var allWorkflows []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" && (len(content.Name) > 4 && (content.Name[len(content.Name)-4:] == ".yml" || content.Name[len(content.Name)-5:] == ".yaml")) {
workflow := Workflow{
Name: content.Name,
Path: content.Path,
State: "active",
}
allWorkflows = append(allWorkflows, workflow)
if len(allWorkflows) >= limit {
break
}
}
}
if len(allWorkflows) >= limit {
break
}
}
if len(allWorkflows) == 0 {
fmt.Println("No workflows found")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "NAME\tSTATE\tPATH"); err != nil {
return fmt.Errorf("failed to write header: %w", err)
}
for _, workflow := range allWorkflows {
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n",
workflow.Name, workflow.State, workflow.Path); err != nil {
return fmt.Errorf("failed to write workflow: %w", err)
}
}
if err := w.Flush(); err != nil {
return fmt.Errorf("failed to flush output: %w", err)
}
return nil
}
func runWorkflowView(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]
// 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)
}
// Display workflow information
fmt.Printf("Name: %s\n", workflow.Name)
fmt.Printf("Path: %s\n", workflow.Path)
fmt.Printf("State: %s\n", workflow.State)
// Get the latest run for this workflow
runsEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?workflow_id=%s&limit=1", owner, name, workflow.Path)
var runList ActionRunList
if err := client.GetJSON(runsEndpoint, &runList); err != nil {
// If we can't get runs, just display workflow info without latest run
return nil
}
if len(runList.WorkflowRuns) > 0 {
run := runList.WorkflowRuns[0]
fmt.Printf("\nLatest run:\n")
fmt.Printf(" Status: %s\n", formatStatus(run.Status))
fmt.Printf(" Event: %s\n", run.Event)
fmt.Printf(" Ref: %s\n", run.PrettyRef)
if createdTime, err := time.Parse(time.RFC3339, run.Created); err == nil {
fmt.Printf(" Created: %s\n", formatTimeSince(createdTime))
}
}
return nil
}
func runWorkflowRun(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]
ref, _ := cmd.Flags().GetString("ref")
fields, _ := cmd.Flags().GetStringSlice("field")
rawFields, _ := cmd.Flags().GetStringSlice("raw-field")
// If no ref is specified, get the repository's default branch
if ref == "" {
repoInfo, _, err := client.GetRepo(owner, name)
if err != nil {
return fmt.Errorf("failed to get repository info: %w", err)
}
ref = repoInfo.DefaultBranch
}
// Build the inputs map
inputs := make(map[string]string)
// Process -f/--field flags
for _, field := range fields {
parts := splitKeyValue(field)
if len(parts) == 2 {
inputs[parts[0]] = parts[1]
}
}
// Process -F/--raw-field flags (same as field for now, file reading can be added later)
for _, field := range rawFields {
parts := splitKeyValue(field)
if len(parts) == 2 {
inputs[parts[0]] = parts[1]
}
}
// Prepare the dispatch request
dispatchReq := map[string]any{
"ref": ref,
}
if len(inputs) > 0 {
dispatchReq["inputs"] = inputs
}
// Trigger the workflow
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, name, workflowIdentifier)
if err := client.PostJSON(endpoint, dispatchReq, nil); err != nil {
return fmt.Errorf("failed to trigger workflow: %w", err)
}
fmt.Printf("✓ Workflow '%s' triggered successfully\n", workflowIdentifier)
fmt.Printf(" Branch/Tag: %s\n", ref)
if len(inputs) > 0 {
fmt.Println(" Inputs:")
for key, value := range inputs {
fmt.Printf(" %s: %s\n", key, value)
}
}
return nil
}
func splitKeyValue(s string) []string {
idx := -1
for i, c := range s {
if c == '=' {
idx = i
break
}
}
if idx == -1 {
return []string{s}
}
return []string{s[:idx], s[idx+1:]}
}
// Secret command implementations
func runActionsSecretList(cmd *cobra.Command, args []string) error {

122
cmd/actions_test.go Normal file
View file

@ -0,0 +1,122 @@
package cmd
import (
"reflect"
"testing"
)
func TestSplitKeyValue(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "valid key=value",
input: "environment=production",
expected: []string{"environment", "production"},
},
{
name: "key with empty value",
input: "key=",
expected: []string{"key", ""},
},
{
name: "value with equals sign",
input: "url=https://example.com?foo=bar",
expected: []string{"url", "https://example.com?foo=bar"},
},
{
name: "no equals sign",
input: "invalid",
expected: []string{"invalid"},
},
{
name: "multiple equals signs",
input: "a=b=c=d",
expected: []string{"a", "b=c=d"},
},
{
name: "empty string",
input: "",
expected: []string{""},
},
{
name: "just equals sign",
input: "=",
expected: []string{"", ""},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := splitKeyValue(tt.input)
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("splitKeyValue(%q) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
func TestFormatStatus(t *testing.T) {
tests := []struct {
name string
status string
expected string
}{
{
name: "success status",
status: "success",
expected: "✓ success",
},
{
name: "failure status",
status: "failure",
expected: "✗ failure",
},
{
name: "cancelled status",
status: "cancelled",
expected: "- cancelled",
},
{
name: "skipped status",
status: "skipped",
expected: "○ skipped",
},
{
name: "in_progress status",
status: "in_progress",
expected: "● in progress",
},
{
name: "running status",
status: "running",
expected: "● in progress",
},
{
name: "queued status",
status: "queued",
expected: "○ queued",
},
{
name: "waiting status",
status: "waiting",
expected: "○ queued",
},
{
name: "unknown status",
status: "unknown",
expected: "unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatStatus(tt.status)
if result != tt.expected {
t.Errorf("formatStatus(%q) = %q, want %q", tt.status, result, tt.expected)
}
})
}
}

View file

@ -1,6 +1,7 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
@ -85,6 +86,57 @@ func (c *Client) GetJSON(path string, result any) error {
return nil
}
// PostJSON performs a POST request to the specified path with JSON body
func (c *Client) PostJSON(path string, body any, result any) error {
baseURL := "https://" + c.hostname
url := baseURL + path
var bodyReader io.Reader
if body != nil {
bodyBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
bodyReader = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequest(http.MethodPost, url, bodyReader)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set authentication header
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token)
}
req.Header.Set("Accept", "application/json")
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)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
err = fmt.Errorf("failed to close response body: %w", closeErr)
}
}()
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))
}
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 nil
}
// GetRawLog performs a GET request and returns the raw response body as string
func (c *Client) GetRawLog(url string) (string, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)