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
|
### Forgejo Actions
|
||||||
|
|
||||||
```bash
|
```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
|
# List workflow runs
|
||||||
fgj actions run list
|
fgj actions run list
|
||||||
|
|
||||||
|
|
@ -324,6 +339,45 @@ fgj/
|
||||||
|
|
||||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
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
|
## License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
|
||||||
313
cmd/actions.go
313
cmd/actions.go
|
|
@ -58,6 +58,28 @@ type ActionTaskList struct {
|
||||||
TotalCount int `json:"total_count"`
|
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{
|
var actionsCmd = &cobra.Command{
|
||||||
Use: "actions",
|
Use: "actions",
|
||||||
Aliases: []string{"action"},
|
Aliases: []string{"action"},
|
||||||
|
|
@ -87,6 +109,36 @@ var runViewCmd = &cobra.Command{
|
||||||
RunE: runRunView,
|
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
|
// Secret commands
|
||||||
var actionsSecretCmd = &cobra.Command{
|
var actionsSecretCmd = &cobra.Command{
|
||||||
Use: "secret",
|
Use: "secret",
|
||||||
|
|
@ -171,6 +223,12 @@ func init() {
|
||||||
runCmd.AddCommand(runListCmd)
|
runCmd.AddCommand(runListCmd)
|
||||||
runCmd.AddCommand(runViewCmd)
|
runCmd.AddCommand(runViewCmd)
|
||||||
|
|
||||||
|
// Add workflow commands (gh workflow compatible)
|
||||||
|
actionsCmd.AddCommand(workflowCmd)
|
||||||
|
workflowCmd.AddCommand(workflowListCmd)
|
||||||
|
workflowCmd.AddCommand(workflowViewCmd)
|
||||||
|
workflowCmd.AddCommand(workflowRunCmd)
|
||||||
|
|
||||||
// Add secret commands
|
// Add secret commands
|
||||||
actionsCmd.AddCommand(actionsSecretCmd)
|
actionsCmd.AddCommand(actionsSecretCmd)
|
||||||
actionsSecretCmd.AddCommand(actionsSecretListCmd)
|
actionsSecretCmd.AddCommand(actionsSecretListCmd)
|
||||||
|
|
@ -194,6 +252,15 @@ func init() {
|
||||||
runViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run")
|
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")
|
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
|
// Add flags for secret commands
|
||||||
addRepoFlags(actionsSecretListCmd)
|
addRepoFlags(actionsSecretListCmd)
|
||||||
addRepoFlags(actionsSecretCreateCmd)
|
addRepoFlags(actionsSecretCreateCmd)
|
||||||
|
|
@ -499,6 +566,252 @@ func formatTimeSince(t time.Time) string {
|
||||||
return fmt.Sprintf("%d days ago", days)
|
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
|
// Secret command implementations
|
||||||
|
|
||||||
func runActionsSecretList(cmd *cobra.Command, args []string) error {
|
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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -85,6 +86,57 @@ func (c *Client) GetJSON(path string, result any) error {
|
||||||
return nil
|
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
|
// GetRawLog performs a GET request and returns the raw response body as string
|
||||||
func (c *Client) GetRawLog(url string) (string, error) {
|
func (c *Client) GetRawLog(url string) (string, error) {
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue