Merge pull request 'feat/various' (#28) from feat/various into main

Reviewed-on: https://codeberg.org/romaintb/fgj/pulls/28
This commit is contained in:
Romain Bertrand 2026-01-28 15:50:13 +01:00
commit 65c686997f
14 changed files with 683 additions and 103 deletions

View file

@ -2,8 +2,10 @@ package cmd
import ( import (
"fmt" "fmt"
"net/http"
"os" "os"
"strconv" "strconv"
"strings"
"text/tabwriter" "text/tabwriter"
"time" "time"
@ -16,17 +18,17 @@ import (
// ActionRun represents a workflow run // ActionRun represents a workflow run
type ActionRun struct { type ActionRun struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
WorkflowID string `json:"workflow_id"` WorkflowID string `json:"workflow_id"`
IndexInRepo int64 `json:"index_in_repo"` IndexInRepo int64 `json:"index_in_repo"`
Event string `json:"event"` Event string `json:"event"`
Status string `json:"status"` Status string `json:"status"`
CommitSHA string `json:"commit_sha"` CommitSHA string `json:"commit_sha"`
PrettyRef string `json:"prettyref"` PrettyRef string `json:"prettyref"`
Created string `json:"created"` Created string `json:"created"`
Updated string `json:"updated"` Updated string `json:"updated"`
Started string `json:"started"` Started string `json:"started"`
} }
// ActionRunList represents a list of workflow runs // ActionRunList represents a list of workflow runs
@ -37,19 +39,19 @@ type ActionRunList struct {
// ActionTask represents a job/task within a workflow run // ActionTask represents a job/task within a workflow run
type ActionTask struct { type ActionTask struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
HeadBranch string `json:"head_branch"` HeadBranch string `json:"head_branch"`
HeadSHA string `json:"head_sha"` HeadSHA string `json:"head_sha"`
RunNumber int64 `json:"run_number"` RunNumber int64 `json:"run_number"`
Event string `json:"event"` Event string `json:"event"`
DisplayTitle string `json:"display_title"` DisplayTitle string `json:"display_title"`
Status string `json:"status"` Status string `json:"status"`
WorkflowID string `json:"workflow_id"` WorkflowID string `json:"workflow_id"`
URL string `json:"url"` URL string `json:"url"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
RunStartedAt string `json:"run_started_at"` RunStartedAt string `json:"run_started_at"`
} }
// ActionTaskList represents a list of tasks/jobs // ActionTaskList represents a list of tasks/jobs
@ -109,6 +111,30 @@ var runViewCmd = &cobra.Command{
RunE: runRunView, RunE: runRunView,
} }
var runWatchCmd = &cobra.Command{
Use: "watch <run-id>",
Short: "Watch a workflow run",
Long: "Poll a workflow run until it completes.",
Args: cobra.ExactArgs(1),
RunE: runRunWatch,
}
var runRerunCmd = &cobra.Command{
Use: "rerun <run-id>",
Short: "Rerun a workflow run",
Long: "Trigger a rerun for a specific workflow run.",
Args: cobra.ExactArgs(1),
RunE: runRunRerun,
}
var runCancelCmd = &cobra.Command{
Use: "cancel <run-id>",
Short: "Cancel a workflow run",
Long: "Cancel a running workflow run.",
Args: cobra.ExactArgs(1),
RunE: runRunCancel,
}
// Workflow commands // Workflow commands
var workflowCmd = &cobra.Command{ var workflowCmd = &cobra.Command{
Use: "workflow", Use: "workflow",
@ -139,6 +165,22 @@ var workflowRunCmd = &cobra.Command{
RunE: runWorkflowRun, RunE: runWorkflowRun,
} }
var workflowEnableCmd = &cobra.Command{
Use: "enable <workflow>",
Short: "Enable a workflow",
Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.",
Args: cobra.ExactArgs(1),
RunE: runWorkflowEnable,
}
var workflowDisableCmd = &cobra.Command{
Use: "disable <workflow>",
Short: "Disable a workflow",
Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.",
Args: cobra.ExactArgs(1),
RunE: runWorkflowDisable,
}
// Secret commands // Secret commands
var actionsSecretCmd = &cobra.Command{ var actionsSecretCmd = &cobra.Command{
Use: "secret", Use: "secret",
@ -222,12 +264,17 @@ func init() {
actionsCmd.AddCommand(runCmd) actionsCmd.AddCommand(runCmd)
runCmd.AddCommand(runListCmd) runCmd.AddCommand(runListCmd)
runCmd.AddCommand(runViewCmd) runCmd.AddCommand(runViewCmd)
runCmd.AddCommand(runWatchCmd)
runCmd.AddCommand(runRerunCmd)
runCmd.AddCommand(runCancelCmd)
// Add workflow commands (gh workflow compatible) // Add workflow commands (gh workflow compatible)
actionsCmd.AddCommand(workflowCmd) actionsCmd.AddCommand(workflowCmd)
workflowCmd.AddCommand(workflowListCmd) workflowCmd.AddCommand(workflowListCmd)
workflowCmd.AddCommand(workflowViewCmd) workflowCmd.AddCommand(workflowViewCmd)
workflowCmd.AddCommand(workflowRunCmd) workflowCmd.AddCommand(workflowRunCmd)
workflowCmd.AddCommand(workflowEnableCmd)
workflowCmd.AddCommand(workflowDisableCmd)
// Add secret commands // Add secret commands
actionsCmd.AddCommand(actionsSecretCmd) actionsCmd.AddCommand(actionsSecretCmd)
@ -246,17 +293,27 @@ func init() {
// Add flags for run commands // Add flags for run commands
addRepoFlags(runListCmd) addRepoFlags(runListCmd)
runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
runListCmd.Flags().Bool("json", false, "Output workflow runs as JSON")
addRepoFlags(runViewCmd) addRepoFlags(runViewCmd)
runViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps") runViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps")
runViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job") runViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
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")
runViewCmd.Flags().Bool("json", false, "Output workflow run as JSON")
addRepoFlags(runWatchCmd)
runWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval")
addRepoFlags(runRerunCmd)
addRepoFlags(runCancelCmd)
// Add flags for workflow commands // Add flags for workflow commands
addRepoFlags(workflowListCmd) addRepoFlags(workflowListCmd)
workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list") workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
workflowListCmd.Flags().Bool("json", false, "Output workflows as JSON")
addRepoFlags(workflowViewCmd) addRepoFlags(workflowViewCmd)
workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON")
addRepoFlags(workflowRunCmd) addRepoFlags(workflowRunCmd)
addRepoFlags(workflowEnableCmd)
addRepoFlags(workflowDisableCmd)
workflowRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)") 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("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)") 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)")
@ -307,6 +364,10 @@ func runRunList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to list runs: %w", err) return fmt.Errorf("failed to list runs: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(runList.WorkflowRuns)
}
if len(runList.WorkflowRuns) == 0 { if len(runList.WorkflowRuns) == 0 {
fmt.Println("No workflow runs found") fmt.Println("No workflow runs found")
return nil return nil
@ -364,6 +425,7 @@ func runRunView(cmd *cobra.Command, args []string) error {
showLog, _ := cmd.Flags().GetBool("log") showLog, _ := cmd.Flags().GetBool("log")
jobIDStr, _ := cmd.Flags().GetString("job") jobIDStr, _ := cmd.Flags().GetString("job")
showLogFailed, _ := cmd.Flags().GetBool("log-failed") showLogFailed, _ := cmd.Flags().GetBool("log-failed")
jsonOutput, _ := cmd.Flags().GetBool("json")
var jobID int64 var jobID int64
if jobIDStr != "" { if jobIDStr != "" {
@ -374,6 +436,10 @@ func runRunView(cmd *cobra.Command, args []string) error {
} }
} }
if jsonOutput && (showLog || showLogFailed) {
return fmt.Errorf("--json cannot be used with --log or --log-failed")
}
// Call the API endpoint directly since SDK doesn't have it yet // Call the API endpoint directly since SDK doesn't have it yet
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID) endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID)
@ -382,6 +448,48 @@ func runRunView(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to get run: %w", err) return fmt.Errorf("failed to get run: %w", err)
} }
needsJobs := verbose || showLog || showLogFailed || jobID > 0
if jsonOutput {
var runTasks []ActionTask
if needsJobs {
tasksEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", owner, name)
var taskList ActionTaskList
if err := client.GetJSON(tasksEndpoint, &taskList); err != nil {
return fmt.Errorf("failed to get tasks: %w", err)
}
for _, task := range taskList.WorkflowRuns {
if task.RunNumber == run.IndexInRepo {
runTasks = append(runTasks, task)
}
}
if jobID > 0 {
var filtered []ActionTask
for _, task := range runTasks {
if task.ID == jobID {
filtered = append(filtered, task)
break
}
}
if len(filtered) == 0 {
return fmt.Errorf("job %d not found in this run", jobID)
}
runTasks = filtered
}
}
payload := struct {
Run ActionRun `json:"run"`
Tasks []ActionTask `json:"tasks,omitempty"`
}{
Run: run,
Tasks: runTasks,
}
return writeJSON(payload)
}
// Display run information // Display run information
fmt.Printf("Title: %s\n", run.Title) fmt.Printf("Title: %s\n", run.Title)
fmt.Printf("Workflow: %s\n", run.WorkflowID) fmt.Printf("Workflow: %s\n", run.WorkflowID)
@ -409,7 +517,6 @@ func runRunView(cmd *cobra.Command, args []string) error {
} }
// Fetch jobs if needed for verbose, log, or job-specific views // Fetch jobs if needed for verbose, log, or job-specific views
needsJobs := verbose || showLog || showLogFailed || jobID > 0
if !needsJobs { if !needsJobs {
return nil return nil
} }
@ -466,7 +573,7 @@ func runRunView(cmd *cobra.Command, args []string) error {
// Case 2: --log or --log-failed (show logs) // Case 2: --log or --log-failed (show logs)
if showLog || showLogFailed { if showLog || showLogFailed {
for _, task := range runTasks { for _, task := range runTasks {
if err := showJobLog(client, owner, name, run.IndexInRepo, task, showLogFailed); err != nil { if err := showJobLog(client, owner, name, task, showLogFailed); err != nil {
fmt.Printf("\nError fetching log for job %s: %v\n", task.Name, err) fmt.Printf("\nError fetching log for job %s: %v\n", task.Name, err)
} }
} }
@ -491,10 +598,122 @@ func runRunView(cmd *cobra.Command, args []string) error {
return nil return nil
} }
func showJobLog(client *api.Client, owner, name string, runNumber int64, task ActionTask, logFailed bool) error { func runRunWatch(cmd *cobra.Command, args []string) error {
// Fetch log from /repos/{owner}/{repo}/actions/runs/{run_number}/jobs/{job_id}/logs cfg, err := config.Load()
logURL := fmt.Sprintf("https://%s/%s/%s/actions/runs/%d/jobs/%d/logs", if err != nil {
client.Hostname(), owner, name, runNumber, task.ID) 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
}
runID, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid run ID: %w", err)
}
interval, _ := cmd.Flags().GetDuration("interval")
if interval <= 0 {
return fmt.Errorf("interval must be greater than 0")
}
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID)
var lastStatus string
for {
var run ActionRun
if err := client.GetJSON(endpoint, &run); err != nil {
return fmt.Errorf("failed to get run: %w", err)
}
if run.Status != lastStatus {
fmt.Printf("Status: %s\n", formatStatus(run.Status))
lastStatus = run.Status
}
if isRunComplete(run.Status) {
fmt.Printf("Run #%d completed with status %s\n", run.IndexInRepo, formatStatus(run.Status))
return nil
}
time.Sleep(interval)
}
}
func runRunRerun(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
}
runID, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid run ID: %w", err)
}
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/rerun", owner, name, runID)
if err := client.PostJSON(endpoint, nil, nil); err != nil {
return fmt.Errorf("failed to rerun workflow: %w", err)
}
fmt.Printf("✓ Rerun requested for run %d\n", runID)
return nil
}
func runRunCancel(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
}
runID, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid run ID: %w", err)
}
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/cancel", owner, name, runID)
if err := client.PostJSON(endpoint, nil, nil); err != nil {
return fmt.Errorf("failed to cancel workflow run: %w", err)
}
fmt.Printf("✓ Cancel requested for run %d\n", runID)
return nil
}
func showJobLog(client *api.Client, owner, name string, task ActionTask, logFailed bool) error {
// Fetch log from API: GET /api/v1/repos/{owner}/{repo}/actions/jobs/{job_id}/logs
logURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/actions/jobs/%d/logs",
client.Hostname(), owner, name, task.ID)
fmt.Printf("\n========================================\n") fmt.Printf("\n========================================\n")
fmt.Printf("Job: %s (ID: %d)\n", task.Name, task.ID) fmt.Printf("Job: %s (ID: %d)\n", task.Name, task.ID)
@ -540,6 +759,15 @@ func formatStatus(status string) string {
} }
} }
func isRunComplete(status string) bool {
switch status {
case "success", "failure", "cancelled", "skipped":
return true
default:
return false
}
}
func formatTimeSince(t time.Time) string { func formatTimeSince(t time.Time) string {
duration := time.Since(t) duration := time.Since(t)
@ -620,10 +848,17 @@ func runWorkflowList(cmd *cobra.Command, args []string) error {
} }
if len(allWorkflows) == 0 { if len(allWorkflows) == 0 {
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(allWorkflows)
}
fmt.Println("No workflows found") fmt.Println("No workflows found")
return nil return nil
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(allWorkflows)
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "NAME\tSTATE\tPATH"); err != nil { if _, err := fmt.Fprintln(w, "NAME\tSTATE\tPATH"); err != nil {
return fmt.Errorf("failed to write header: %w", err) return fmt.Errorf("failed to write header: %w", err)
@ -661,37 +896,31 @@ func runWorkflowView(cmd *cobra.Command, args []string) error {
} }
workflowIdentifier := args[0] workflowIdentifier := args[0]
workflow, err := findWorkflow(client, owner, name, workflowIdentifier)
// Find the workflow by listing from both .gitea/workflows and .forgejo/workflows if err != nil {
var workflow *Workflow return err
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 { jsonOutput, _ := cmd.Flags().GetBool("json")
return fmt.Errorf("workflow '%s' not found", workflowIdentifier)
var latestRun *ActionRun
// 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 && len(runList.WorkflowRuns) > 0 {
latestRun = &runList.WorkflowRuns[0]
}
if jsonOutput {
payload := struct {
Workflow *Workflow `json:"workflow"`
LatestRun *ActionRun `json:"latest_run,omitempty"`
}{
Workflow: workflow,
LatestRun: latestRun,
}
return writeJSON(payload)
} }
// Display workflow information // Display workflow information
@ -699,21 +928,12 @@ func runWorkflowView(cmd *cobra.Command, args []string) error {
fmt.Printf("Path: %s\n", workflow.Path) fmt.Printf("Path: %s\n", workflow.Path)
fmt.Printf("State: %s\n", workflow.State) fmt.Printf("State: %s\n", workflow.State)
// Get the latest run for this workflow if latestRun != nil {
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("\nLatest run:\n")
fmt.Printf(" Status: %s\n", formatStatus(run.Status)) fmt.Printf(" Status: %s\n", formatStatus(latestRun.Status))
fmt.Printf(" Event: %s\n", run.Event) fmt.Printf(" Event: %s\n", latestRun.Event)
fmt.Printf(" Ref: %s\n", run.PrettyRef) fmt.Printf(" Ref: %s\n", latestRun.PrettyRef)
if createdTime, err := time.Parse(time.RFC3339, run.Created); err == nil { if createdTime, err := time.Parse(time.RFC3339, latestRun.Created); err == nil {
fmt.Printf(" Created: %s\n", formatTimeSince(createdTime)) fmt.Printf(" Created: %s\n", formatTimeSince(createdTime))
} }
} }
@ -798,6 +1018,96 @@ func runWorkflowRun(cmd *cobra.Command, args []string) error {
return nil return nil
} }
func runWorkflowEnable(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]
workflow, err := findWorkflow(client, owner, name, workflowIdentifier)
if err != nil {
return err
}
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/enable", owner, name, workflow.Name)
// Try PUT first (correct method per GitHub/Gitea API spec)
status, err := client.DoJSON(http.MethodPut, endpoint, nil, nil)
if err != nil && (status == http.StatusNotFound || status == http.StatusMethodNotAllowed) {
// Fall back to POST for older versions
status, err = client.DoJSON(http.MethodPost, endpoint, nil, nil)
}
if err != nil {
if status == http.StatusNotFound && strings.Contains(err.Error(), "404") {
return fmt.Errorf("failed to enable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+. " +
"Your instance does not support the workflow enable/disable API endpoints yet. " +
"You can enable workflows via the web UI instead")
}
return fmt.Errorf("failed to enable workflow: %w", err)
}
fmt.Printf("✓ Workflow '%s' enabled\n", workflow.Name)
return nil
}
func runWorkflowDisable(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]
workflow, err := findWorkflow(client, owner, name, workflowIdentifier)
if err != nil {
return err
}
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/disable", owner, name, workflow.Name)
// Try PUT first (correct method per GitHub/Gitea API spec)
status, err := client.DoJSON(http.MethodPut, endpoint, nil, nil)
if err != nil && (status == http.StatusNotFound || status == http.StatusMethodNotAllowed) {
// Fall back to POST for older versions
status, err = client.DoJSON(http.MethodPost, endpoint, nil, nil)
}
if err != nil {
if status == http.StatusNotFound && strings.Contains(err.Error(), "404") {
return fmt.Errorf("failed to disable workflow: this feature requires Forgejo 15.0+ or Gitea 1.24+. " +
"Your instance does not support the workflow enable/disable API endpoints yet. " +
"You can disable workflows via the web UI instead")
}
return fmt.Errorf("failed to disable workflow: %w", err)
}
fmt.Printf("✓ Workflow '%s' disabled\n", workflow.Name)
return nil
}
func splitKeyValue(s string) []string { func splitKeyValue(s string) []string {
idx := -1 idx := -1
for i, c := range s { for i, c := range s {
@ -812,6 +1122,29 @@ func splitKeyValue(s string) []string {
return []string{s[:idx], s[idx+1:]} return []string{s[:idx], s[idx+1:]}
} }
func findWorkflow(client *api.Client, owner, name, workflowIdentifier string) (*Workflow, error) {
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 {
continue
}
for _, content := range contents {
if content.Type == "file" && (content.Name == workflowIdentifier || content.Path == workflowIdentifier) {
return &Workflow{
Name: content.Name,
Path: content.Path,
State: "active",
}, nil
}
}
}
return nil, fmt.Errorf("workflow '%s' not found", workflowIdentifier)
}
// Secret command implementations // Secret command implementations
func runActionsSecretList(cmd *cobra.Command, args []string) error { func runActionsSecretList(cmd *cobra.Command, args []string) error {

View file

@ -7,9 +7,10 @@ import (
"strings" "strings"
"syscall" "syscall"
"github.com/spf13/cobra"
"codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/api"
"codeberg.org/romaintb/fgj/internal/config" "codeberg.org/romaintb/fgj/internal/config"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/term" "golang.org/x/term"
) )
@ -33,13 +34,31 @@ var authStatusCmd = &cobra.Command{
RunE: runAuthStatus, RunE: runAuthStatus,
} }
var authLogoutCmd = &cobra.Command{
Use: "logout",
Short: "Remove authentication for a Forgejo instance",
Long: "Remove authentication for a configured Forgejo instance.",
RunE: runAuthLogout,
}
var authTokenCmd = &cobra.Command{
Use: "token",
Short: "Print the stored authentication token",
Long: "Print the stored authentication token for a configured Forgejo instance.",
RunE: runAuthToken,
}
func init() { func init() {
rootCmd.AddCommand(authCmd) rootCmd.AddCommand(authCmd)
authCmd.AddCommand(authLoginCmd) authCmd.AddCommand(authLoginCmd)
authCmd.AddCommand(authStatusCmd) authCmd.AddCommand(authStatusCmd)
authCmd.AddCommand(authLogoutCmd)
authCmd.AddCommand(authTokenCmd)
authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)") authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token") authLoginCmd.Flags().StringP("token", "t", "", "Personal access token")
authLogoutCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
authTokenCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
} }
func runAuthLogin(cmd *cobra.Command, args []string) error { func runAuthLogin(cmd *cobra.Command, args []string) error {
@ -87,9 +106,9 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
} }
cfg.SetHost(hostname, config.HostConfig{ cfg.SetHost(hostname, config.HostConfig{
Hostname: hostname, Hostname: hostname,
Token: token, Token: token,
User: user.UserName, User: user.UserName,
GitProtocol: "https", GitProtocol: "https",
}) })
@ -121,3 +140,66 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
return nil return nil
} }
func runAuthLogout(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
hostname, _ := cmd.Flags().GetString("hostname")
resolved, err := resolveAuthHostname(cfg, hostname)
if err != nil {
return err
}
delete(cfg.Hosts, resolved)
if err := cfg.Save(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("✓ Logged out from %s\n", resolved)
return nil
}
func runAuthToken(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
hostname, _ := cmd.Flags().GetString("hostname")
resolved, err := resolveAuthHostname(cfg, hostname)
if err != nil {
return err
}
fmt.Println(cfg.Hosts[resolved].Token)
return nil
}
func resolveAuthHostname(cfg *config.Config, hostname string) (string, error) {
if hostname == "" {
hostname = viper.GetString("hostname")
}
if hostname == "" {
hostname = os.Getenv("FGJ_HOST")
}
if hostname == "" {
hostname = getDetectedHost()
}
if hostname == "" && len(cfg.Hosts) == 1 {
for host := range cfg.Hosts {
hostname = host
}
}
if hostname == "" {
hostname = "codeberg.org"
}
if _, ok := cfg.Hosts[hostname]; !ok {
return "", fmt.Errorf("no configuration found for host %s", hostname)
}
return hostname, nil
}

37
cmd/completion.go Normal file
View file

@ -0,0 +1,37 @@
package cmd
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
)
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts",
Long: "Generate shell completion scripts for fgj.",
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
DisableFlagsInUseLine: true,
RunE: func(cmd *cobra.Command, args []string) error {
var out io.Writer = os.Stdout
switch args[0] {
case "bash":
return rootCmd.GenBashCompletion(out)
case "zsh":
return rootCmd.GenZshCompletion(out)
case "fish":
return rootCmd.GenFishCompletion(out, true)
case "powershell":
return rootCmd.GenPowerShellCompletionWithDesc(out)
default:
return fmt.Errorf("unsupported shell: %s", args[0])
}
},
}
func init() {
rootCmd.AddCommand(completionCmd)
}

View file

@ -76,8 +76,10 @@ func init() {
issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all")
issueListCmd.Flags().Bool("json", false, "Output issues as JSON")
issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueViewCmd.Flags().Bool("json", false, "Output issue as JSON")
issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue") issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue")
@ -133,17 +135,26 @@ func runIssueList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to list issues: %w", err) return fmt.Errorf("failed to list issues: %w", err)
} }
if len(issues) == 0 { nonPRIssues := make([]*gitea.Issue, 0, len(issues))
for _, issue := range issues {
if issue.PullRequest == nil {
nonPRIssues = append(nonPRIssues, issue)
}
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(nonPRIssues)
}
if len(nonPRIssues) == 0 {
fmt.Printf("No %s issues in %s/%s\n", state, owner, name) fmt.Printf("No %s issues in %s/%s\n", state, owner, name)
return nil return nil
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n") _, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n")
for _, issue := range issues { for _, issue := range nonPRIssues {
if issue.PullRequest == nil { _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State)
_, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State)
}
} }
_ = w.Flush() _ = w.Flush()
@ -177,6 +188,23 @@ func runIssueView(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to get issue: %w", err) return fmt.Errorf("failed to get issue: %w", err)
} }
var comments []*gitea.Comment
comments, _, err = client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{})
if err != nil {
comments = nil
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
payload := struct {
Issue *gitea.Issue `json:"issue"`
Comments []*gitea.Comment `json:"comments,omitempty"`
}{
Issue: issue,
Comments: comments,
}
return writeJSON(payload)
}
fmt.Printf("Issue #%d\n", issue.Index) fmt.Printf("Issue #%d\n", issue.Index)
fmt.Printf("Title: %s\n", issue.Title) fmt.Printf("Title: %s\n", issue.Title)
fmt.Printf("State: %s\n", issue.State) fmt.Printf("State: %s\n", issue.State)
@ -187,8 +215,7 @@ func runIssueView(cmd *cobra.Command, args []string) error {
fmt.Printf("\n%s\n", issue.Body) fmt.Printf("\n%s\n", issue.Body)
} }
comments, _, err := client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{}) if len(comments) > 0 {
if err == nil && len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments)) fmt.Printf("\nComments (%d):\n", len(comments))
for _, comment := range comments { for _, comment := range comments {
fmt.Printf("\n---\n%s (@%s) - %s\n%s\n", fmt.Printf("\n---\n%s (@%s) - %s\n%s\n",

12
cmd/json.go Normal file
View file

@ -0,0 +1,12 @@
package cmd
import (
"encoding/json"
"os"
)
func writeJSON(value any) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(value)
}

49
cmd/manpages.go Normal file
View file

@ -0,0 +1,49 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
)
var manpagesCmd = &cobra.Command{
Use: "manpages",
Short: "Generate manpages",
Long: "Generate manpages for fgj commands.",
RunE: func(cmd *cobra.Command, args []string) error {
dir, _ := cmd.Flags().GetString("dir")
if dir == "" {
return fmt.Errorf("directory is required")
}
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create %s: %w", dir, err)
}
absDir, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("failed to resolve %s: %w", dir, err)
}
header := &doc.GenManHeader{
Title: "FGJ",
Section: "1",
}
if err := doc.GenManTree(rootCmd, header, absDir); err != nil {
return fmt.Errorf("failed to generate manpages: %w", err)
}
fmt.Printf("Manpages generated in %s\n", absDir)
return nil
},
}
func init() {
rootCmd.AddCommand(manpagesCmd)
manpagesCmd.Flags().String("dir", "", "Output directory for manpages")
_ = manpagesCmd.MarkFlagRequired("dir")
}

View file

@ -8,9 +8,9 @@ import (
"text/tabwriter" "text/tabwriter"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/spf13/cobra"
"codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/api"
"codeberg.org/romaintb/fgj/internal/config" "codeberg.org/romaintb/fgj/internal/config"
"github.com/spf13/cobra"
) )
var prCmd = &cobra.Command{ var prCmd = &cobra.Command{
@ -59,8 +59,10 @@ func init() {
prListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") prListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all")
prListCmd.Flags().Bool("json", false, "Output pull requests as JSON")
prViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prViewCmd.Flags().Bool("json", false, "Output pull request as JSON")
prCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prCreateCmd.Flags().StringP("title", "t", "", "Title for the pull request") prCreateCmd.Flags().StringP("title", "t", "", "Title for the pull request")
@ -111,6 +113,10 @@ func runPRList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to list pull requests: %w", err) return fmt.Errorf("failed to list pull requests: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(prs)
}
if len(prs) == 0 { if len(prs) == 0 {
fmt.Printf("No %s pull requests in %s/%s\n", state, owner, name) fmt.Printf("No %s pull requests in %s/%s\n", state, owner, name)
return nil return nil
@ -153,6 +159,10 @@ func runPRView(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to get pull request: %w", err) return fmt.Errorf("failed to get pull request: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(pr)
}
fmt.Printf("Pull Request #%d\n", pr.Index) fmt.Printf("Pull Request #%d\n", pr.Index)
fmt.Printf("Title: %s\n", pr.Title) fmt.Printf("Title: %s\n", pr.Title)
fmt.Printf("State: %s\n", pr.State) fmt.Printf("State: %s\n", pr.State)
@ -279,4 +289,3 @@ func runPRMerge(cmd *cobra.Command, args []string) error {
return nil return nil
} }

View file

@ -72,8 +72,10 @@ func init() {
releaseListCmd.Flags().Bool("draft", false, "Filter by draft status") releaseListCmd.Flags().Bool("draft", false, "Filter by draft status")
releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status") releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status")
releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch") releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch")
releaseListCmd.Flags().Bool("json", false, "Output releases as JSON")
releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseViewCmd.Flags().Bool("json", false, "Output release as JSON")
releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)") releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)")
@ -146,6 +148,10 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
releases = releases[:limit] releases = releases[:limit]
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(releases)
}
if len(releases) == 0 { if len(releases) == 0 {
fmt.Printf("No releases in %s/%s\n", owner, name) fmt.Printf("No releases in %s/%s\n", owner, name)
return nil return nil
@ -186,6 +192,22 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
return err return err
} }
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
if err != nil {
return err
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
payload := struct {
Release *gitea.Release `json:"release"`
Assets []*gitea.Attachment `json:"assets,omitempty"`
}{
Release: release,
Assets: attachments,
}
return writeJSON(payload)
}
fmt.Printf("Release %s\n", release.TagName) fmt.Printf("Release %s\n", release.TagName)
fmt.Printf("Title: %s\n", release.Title) fmt.Printf("Title: %s\n", release.Title)
fmt.Printf("Type: %s\n", releaseType(release)) fmt.Printf("Type: %s\n", releaseType(release))
@ -206,10 +228,6 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
fmt.Printf("\n%s\n", release.Note) fmt.Printf("\n%s\n", release.Note)
} }
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
if err != nil {
return err
}
if len(attachments) > 0 { if len(attachments) > 0 {
fmt.Printf("\nAssets (%d):\n", len(attachments)) fmt.Printf("\nAssets (%d):\n", len(attachments))
for _, asset := range attachments { for _, asset := range attachments {

View file

@ -8,9 +8,9 @@ import (
"text/tabwriter" "text/tabwriter"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/spf13/cobra"
"codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/api"
"codeberg.org/romaintb/fgj/internal/config" "codeberg.org/romaintb/fgj/internal/config"
"github.com/spf13/cobra"
) )
var repoCmd = &cobra.Command{ var repoCmd = &cobra.Command{

View file

@ -5,9 +5,9 @@ import (
"os" "os"
"strings" "strings"
"codeberg.org/romaintb/fgj/internal/git"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"codeberg.org/romaintb/fgj/internal/git"
) )
var cfgFile string var cfgFile string

2
go.mod
View file

@ -12,6 +12,7 @@ require (
require ( require (
github.com/42wim/httpsig v1.2.3 // indirect github.com/42wim/httpsig v1.2.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect
@ -21,6 +22,7 @@ require (
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect

2
go.sum
View file

@ -2,6 +2,7 @@ code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -38,6 +39,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=

View file

@ -88,6 +88,13 @@ func (c *Client) GetJSON(path string, result any) error {
// PostJSON performs a POST request to the specified path with JSON body // PostJSON performs a POST request to the specified path with JSON body
func (c *Client) PostJSON(path string, body any, result any) error { func (c *Client) PostJSON(path string, body any, result any) error {
_, err := c.DoJSON(http.MethodPost, path, body, result)
return err
}
// DoJSON performs an HTTP request with a JSON body and decodes the JSON response.
// Returns the HTTP status code and any error encountered.
func (c *Client) DoJSON(method string, path string, body any, result any) (int, error) {
baseURL := "https://" + c.hostname baseURL := "https://" + c.hostname
url := baseURL + path url := baseURL + path
@ -95,14 +102,14 @@ func (c *Client) PostJSON(path string, body any, result any) error {
if body != nil { if body != nil {
bodyBytes, err := json.Marshal(body) bodyBytes, err := json.Marshal(body)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err) return 0, fmt.Errorf("failed to marshal request body: %w", err)
} }
bodyReader = bytes.NewReader(bodyBytes) bodyReader = bytes.NewReader(bodyBytes)
} }
req, err := http.NewRequest(http.MethodPost, url, bodyReader) req, err := http.NewRequest(method, url, bodyReader)
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return 0, fmt.Errorf("failed to create request: %w", err)
} }
// Set authentication header // Set authentication header
@ -110,12 +117,14 @@ func (c *Client) PostJSON(path string, body any, result any) error {
req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Authorization", "token "+c.token)
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json") if body != nil {
req.Header.Set("Content-Type", "application/json")
}
httpClient := &http.Client{} httpClient := &http.Client{}
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to perform request: %w", err) return 0, fmt.Errorf("failed to perform request: %w", err)
} }
defer func() { defer func() {
if closeErr := resp.Body.Close(); closeErr != nil && err == nil { if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
@ -125,16 +134,16 @@ func (c *Client) PostJSON(path string, body any, result any) error {
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) return resp.StatusCode, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
} }
if result != nil && resp.StatusCode != http.StatusNoContent { if result != nil && resp.StatusCode != http.StatusNoContent {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil { if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("failed to decode response: %w", err) return resp.StatusCode, fmt.Errorf("failed to decode response: %w", err)
} }
} }
return nil return resp.StatusCode, 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

View file

@ -14,9 +14,9 @@ type Config struct {
} }
type HostConfig struct { type HostConfig struct {
Hostname string `yaml:"hostname"` Hostname string `yaml:"hostname"`
Token string `yaml:"token"` Token string `yaml:"token"`
User string `yaml:"user,omitempty"` User string `yaml:"user,omitempty"`
GitProtocol string `yaml:"git_protocol,omitempty"` GitProtocol string `yaml:"git_protocol,omitempty"`
} }