diff --git a/README.md b/README.md index 6d50d40..f16663e 100644 --- a/README.md +++ b/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,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 diff --git a/cmd/actions.go b/cmd/actions.go index bedb839..53f5a58 100644 --- a/cmd/actions.go +++ b/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 ", + 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 ", + 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 { diff --git a/cmd/actions_test.go b/cmd/actions_test.go new file mode 100644 index 0000000..f7d404e --- /dev/null +++ b/cmd/actions_test.go @@ -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) + } + }) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index dd180b9..93904c9 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -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)