feat: implement workflow list/view/run

This commit is contained in:
Romain Bertrand 2026-01-16 10:51:37 +01:00
parent d445e55737
commit 79df4eb780
4 changed files with 541 additions and 0 deletions

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 {