feat: implement workflow list/view/run
This commit is contained in:
parent
d445e55737
commit
79df4eb780
4 changed files with 541 additions and 0 deletions
54
README.md
54
README.md
|
|
@ -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,45 @@ 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
|
||||
|
||||
**Implemented:**
|
||||
- ✅ `workflow list` - List all workflows
|
||||
- ✅ `workflow view` - View workflow details and latest run
|
||||
- ✅ `workflow run` - Trigger workflow_dispatch with inputs and ref support
|
||||
- ✅ `run list` - List workflow runs
|
||||
- ✅ `run view` - View run details, jobs, and logs
|
||||
- ✅ `secret list/create/delete` - Manage repository secrets
|
||||
- ✅ `variable list/get/create/update/delete` - Manage repository variables
|
||||
|
||||
**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
|
||||
- ❌ Organization-level secrets and variables
|
||||
|
||||
### Other GitHub CLI Features
|
||||
|
||||
Some other `gh` features that could be added in the future:
|
||||
- ❌ `gh gist` - Gist management (if Forgejo adds gist support)
|
||||
- ❌ `gh project` - Project board management
|
||||
- ❌ `gh label` - Label management
|
||||
- ❌ `gh milestone` - Milestone management
|
||||
- ❌ `gh ssh-key` - SSH key management
|
||||
- ❌ `gh gpg-key` - GPG key management
|
||||
- ❌ `gh org` - Organization management
|
||||
- ❌ `gh codespace` - Codespace management (N/A for Forgejo)
|
||||
- ❌ `gh extension` - Extension management
|
||||
|
||||
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
|
||||
|
|
|
|||
313
cmd/actions.go
313
cmd/actions.go
|
|
@ -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
122
cmd/actions_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue