From ca17526594cead59d05157b423c5edd5c25ad944 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Mon, 8 Dec 2025 11:16:35 +0100 Subject: [PATCH] feat: basic initial support for actions --- cmd/actions.go | 697 +++++++++++++++++++++++++++++++++++++++++ go.mod | 15 +- go.sum | 29 +- internal/api/client.go | 46 +++ 4 files changed, 767 insertions(+), 20 deletions(-) create mode 100644 cmd/actions.go diff --git a/cmd/actions.go b/cmd/actions.go new file mode 100644 index 0000000..ccceaab --- /dev/null +++ b/cmd/actions.go @@ -0,0 +1,697 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + "text/tabwriter" + "time" + + "code.gitea.io/sdk/gitea" + "github.com/spf13/cobra" + + "codeberg.org/romaintb/fgj/internal/api" + "codeberg.org/romaintb/fgj/internal/config" +) + +// ActionRun represents a workflow run +type ActionRun struct { + ID int64 `json:"id"` + Title string `json:"title"` + WorkflowID string `json:"workflow_id"` + IndexInRepo int64 `json:"index_in_repo"` + Event string `json:"event"` + Status string `json:"status"` + CommitSHA string `json:"commit_sha"` + PrettyRef string `json:"prettyref"` + Created string `json:"created"` + Updated string `json:"updated"` + Started string `json:"started"` +} + +// ActionRunList represents a list of workflow runs +type ActionRunList struct { + TotalCount int `json:"total_count"` + WorkflowRuns []ActionRun `json:"workflow_runs"` +} + +// ActionTask represents a job/task within a workflow run +type ActionTask struct { + ID int64 `json:"id"` + Name string `json:"name"` + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + RunNumber int64 `json:"run_number"` + Event string `json:"event"` + DisplayTitle string `json:"display_title"` + Status string `json:"status"` + WorkflowID string `json:"workflow_id"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + RunStartedAt string `json:"run_started_at"` +} + +// ActionTaskList represents a list of tasks/jobs +type ActionTaskList struct { + WorkflowRuns []ActionTask `json:"workflow_runs"` + TotalCount int `json:"total_count"` +} + +var actionsCmd = &cobra.Command{ + Use: "actions", + Aliases: []string{"action"}, + Short: "Manage Forgejo Actions", + Long: "View and manage workflows, runs, secrets, and variables for Forgejo Actions in your repositories.", +} + +// Run commands (compatible with gh run) +var runCmd = &cobra.Command{ + Use: "run", + Short: "View and manage workflow runs", + Long: "List, view, and manage workflow runs.", +} + +var runListCmd = &cobra.Command{ + Use: "list", + Short: "List recent workflow runs", + Long: "List recent workflow runs for a repository.", + RunE: runRunList, +} + +var runViewCmd = &cobra.Command{ + Use: "view ", + Short: "View a workflow run", + Long: "View details about a specific workflow run.", + Args: cobra.ExactArgs(1), + RunE: runRunView, +} + +// Secret commands +var actionsSecretCmd = &cobra.Command{ + Use: "secret", + Short: "Manage repository secrets", + Long: "List, create, and delete secrets for Forgejo Actions.", +} + +var actionsSecretListCmd = &cobra.Command{ + Use: "list", + Short: "List repository secrets", + Long: "List all secrets for a repository.", + RunE: runActionsSecretList, +} + +var actionsSecretCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create or update a repository secret", + Long: "Create or update a secret for Forgejo Actions. The secret value will be read from stdin.", + Args: cobra.ExactArgs(1), + RunE: runActionsSecretCreate, +} + +var actionsSecretDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a repository secret", + Long: "Delete a secret from Forgejo Actions.", + Args: cobra.ExactArgs(1), + RunE: runActionsSecretDelete, +} + +// Variable commands +var actionsVariableCmd = &cobra.Command{ + Use: "variable", + Short: "Manage repository variables", + Long: "List, get, create, update, and delete variables for Forgejo Actions.", +} + +var actionsVariableListCmd = &cobra.Command{ + Use: "list", + Short: "List repository variables", + Long: "List all variables for a repository.", + RunE: runActionsVariableList, +} + +var actionsVariableGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a repository variable", + Long: "Get the value of a specific repository variable.", + Args: cobra.ExactArgs(1), + RunE: runActionsVariableGet, +} + +var actionsVariableCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a repository variable", + Long: "Create a new variable for Forgejo Actions.", + Args: cobra.ExactArgs(2), + RunE: runActionsVariableCreate, +} + +var actionsVariableUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a repository variable", + Long: "Update an existing variable for Forgejo Actions.", + Args: cobra.ExactArgs(2), + RunE: runActionsVariableUpdate, +} + +var actionsVariableDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a repository variable", + Long: "Delete a variable from Forgejo Actions.", + Args: cobra.ExactArgs(1), + RunE: runActionsVariableDelete, +} + +func init() { + rootCmd.AddCommand(actionsCmd) + + // Add run commands (gh run compatible) + actionsCmd.AddCommand(runCmd) + runCmd.AddCommand(runListCmd) + runCmd.AddCommand(runViewCmd) + + // Add secret commands + actionsCmd.AddCommand(actionsSecretCmd) + actionsSecretCmd.AddCommand(actionsSecretListCmd) + actionsSecretCmd.AddCommand(actionsSecretCreateCmd) + actionsSecretCmd.AddCommand(actionsSecretDeleteCmd) + + // Add variable commands + actionsCmd.AddCommand(actionsVariableCmd) + actionsVariableCmd.AddCommand(actionsVariableListCmd) + actionsVariableCmd.AddCommand(actionsVariableGetCmd) + actionsVariableCmd.AddCommand(actionsVariableCreateCmd) + actionsVariableCmd.AddCommand(actionsVariableUpdateCmd) + actionsVariableCmd.AddCommand(actionsVariableDeleteCmd) + + // Add flags for run commands + addRepoFlags(runListCmd) + runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") + addRepoFlags(runViewCmd) + runViewCmd.Flags().BoolP("verbose", "v", false, "Show job details") + runViewCmd.Flags().BoolP("log", "", false, "Show logs for all jobs") + runViewCmd.Flags().Int64P("job", "j", 0, "Show logs for a specific job ID") + + // Add flags for secret commands + addRepoFlags(actionsSecretListCmd) + addRepoFlags(actionsSecretCreateCmd) + addRepoFlags(actionsSecretDeleteCmd) + + // Add flags for variable commands + addRepoFlags(actionsVariableListCmd) + addRepoFlags(actionsVariableGetCmd) + addRepoFlags(actionsVariableCreateCmd) + addRepoFlags(actionsVariableUpdateCmd) + addRepoFlags(actionsVariableDeleteCmd) +} + +func addRepoFlags(cmd *cobra.Command) { + cmd.Flags().StringP("repo", "R", "", "Repository in owner/name format (auto-detected from git if not specified)") +} + +// Run command implementations + +func runRunList(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, "") + 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") + + // Call the API endpoint directly since SDK doesn't have it yet + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?limit=%d", owner, name, limit) + + var runList ActionRunList + if err := client.GetJSON(endpoint, &runList); err != nil { + return fmt.Errorf("failed to list runs: %w", err) + } + + if len(runList.WorkflowRuns) == 0 { + fmt.Println("No workflow runs found") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(w, "STATUS\tTITLE\tWORKFLOW\tEVENT\tID\tCREATED"); err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + + for _, run := range runList.WorkflowRuns { + createdTime, err := time.Parse(time.RFC3339, run.Created) + if err != nil { + createdTime = time.Now() + } + + timeStr := formatTimeSince(createdTime) + + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", + formatStatus(run.Status), run.Title, run.WorkflowID, run.Event, run.ID, timeStr); err != nil { + return fmt.Errorf("failed to write run: %w", err) + } + } + + if err := w.Flush(); err != nil { + return fmt.Errorf("failed to flush output: %w", err) + } + + return nil +} + +func runRunView(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, "") + 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) + } + + verbose, _ := cmd.Flags().GetBool("verbose") + showLog, _ := cmd.Flags().GetBool("log") + jobID, _ := cmd.Flags().GetInt64("job") + + // 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) + + var run ActionRun + if err := client.GetJSON(endpoint, &run); err != nil { + return fmt.Errorf("failed to get run: %w", err) + } + + // Display run information + fmt.Printf("Title: %s\n", run.Title) + fmt.Printf("Workflow: %s\n", run.WorkflowID) + fmt.Printf("Run: #%d\n", run.IndexInRepo) + fmt.Printf("Status: %s\n", formatStatus(run.Status)) + fmt.Printf("Event: %s\n", run.Event) + fmt.Printf("Ref: %s\n", run.PrettyRef) + + commit := run.CommitSHA + if len(commit) > 8 { + commit = commit[:8] + } + fmt.Printf("Commit: %s\n", commit) + + if createdTime, err := time.Parse(time.RFC3339, run.Created); err == nil { + fmt.Printf("Created: %s\n", createdTime.Format("2006-01-02 15:04:05")) + } + if run.Started != "" { + if startedTime, err := time.Parse(time.RFC3339, run.Started); err == nil { + fmt.Printf("Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) + } + } + if updatedTime, err := time.Parse(time.RFC3339, run.Updated); err == nil { + fmt.Printf("Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) + } + + // If --log or --job flag is provided, or --verbose, fetch jobs + if verbose || showLog || jobID > 0 { + 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) + } + + // Filter tasks for this run number + var runTasks []ActionTask + for _, task := range taskList.WorkflowRuns { + if task.RunNumber == run.IndexInRepo { + runTasks = append(runTasks, task) + } + } + + if len(runTasks) == 0 { + fmt.Println("\nNo jobs found for this run") + return nil + } + + // Show jobs if verbose (and not showing logs) + if verbose && !showLog && jobID == 0 { + fmt.Println("\nJobs:") + for _, task := range runTasks { + fmt.Printf("\n %s - %s (ID: %d)\n", formatStatus(task.Status), task.Name, task.ID) + if startedTime, err := time.Parse(time.RFC3339, task.RunStartedAt); err == nil { + fmt.Printf(" Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) + } + if updatedTime, err := time.Parse(time.RFC3339, task.UpdatedAt); err == nil { + fmt.Printf(" Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) + } + } + return nil + } + + // Show logs for specific job or all jobs + if showLog || jobID > 0 { + tasksToShow := runTasks + if jobID > 0 { + // Filter to specific job + tasksToShow = nil + for _, task := range runTasks { + if task.ID == jobID { + tasksToShow = []ActionTask{task} + break + } + } + if len(tasksToShow) == 0 { + return fmt.Errorf("job %d not found in this run", jobID) + } + } + + for _, task := range tasksToShow { + if err := showJobLog(client, owner, name, run.IndexInRepo, task); err != nil { + fmt.Printf("\nError fetching log for job %s: %v\n", task.Name, err) + } + } + } + } + + return nil +} + +func showJobLog(client *api.Client, owner, name string, runNumber int64, task ActionTask) error { + // Fetch log from /repos/{owner}/{repo}/actions/runs/{run_number}/jobs/{job_id}/logs + logURL := fmt.Sprintf("https://%s/%s/%s/actions/runs/%d/jobs/%d/logs", + client.Hostname(), owner, name, runNumber, task.ID) + + fmt.Printf("\n========================================\n") + fmt.Printf("Job: %s (ID: %d)\n", task.Name, task.ID) + fmt.Printf("Status: %s\n", formatStatus(task.Status)) + fmt.Printf("========================================\n\n") + + // Use GetRawLog helper + logContent, err := client.GetRawLog(logURL) + if err != nil { + return err + } + + fmt.Print(logContent) + fmt.Println() + + return nil +} + +func formatStatus(status string) string { + switch status { + case "success": + return "✓ success" + case "failure": + return "✗ failure" + case "cancelled": + return "- cancelled" + case "skipped": + return "○ skipped" + case "in_progress", "running": + return "● in progress" + case "queued", "waiting": + return "○ queued" + default: + return status + } +} + +func formatTimeSince(t time.Time) string { + duration := time.Since(t) + + if duration < time.Minute { + return "just now" + } else if duration < time.Hour { + mins := int(duration.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + } else if duration < 24*time.Hour { + hours := int(duration.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + } + + days := int(duration.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) +} + +// Secret command implementations + +func runActionsSecretList(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, "") + 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 + } + + secrets, _, err := client.ListRepoActionSecret(owner, name, gitea.ListRepoActionSecretOption{}) + if err != nil { + return fmt.Errorf("failed to list secrets: %w", err) + } + + if len(secrets) == 0 { + fmt.Println("No secrets found") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(w, "NAME\tCREATED"); err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + for _, secret := range secrets { + if _, err := fmt.Fprintf(w, "%s\t%s\n", secret.Name, secret.Created.Format("2006-01-02 15:04:05")); err != nil { + return fmt.Errorf("failed to write secret: %w", err) + } + } + if err := w.Flush(); err != nil { + return fmt.Errorf("failed to flush output: %w", err) + } + + return nil +} + +func runActionsSecretCreate(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, "") + 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 + } + + secretName := args[0] + + // Read secret value from stdin + fmt.Print("Enter secret value: ") + var secretValue string + _, err = fmt.Scanln(&secretValue) + if err != nil { + return fmt.Errorf("failed to read secret value: %w", err) + } + + opt := gitea.CreateSecretOption{ + Name: secretName, + Data: secretValue, + } + + _, err = client.CreateRepoActionSecret(owner, name, opt) + if err != nil { + return fmt.Errorf("failed to create secret: %w", err) + } + + fmt.Printf("Secret '%s' created successfully\n", secretName) + return nil +} + +func runActionsSecretDelete(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, "") + 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 + } + + secretName := args[0] + + _, err = client.DeleteRepoActionSecret(owner, name, secretName) + if err != nil { + return fmt.Errorf("failed to delete secret: %w", err) + } + + fmt.Printf("Secret '%s' deleted successfully\n", secretName) + return nil +} + +// Variable command implementations + +func runActionsVariableList(cmd *cobra.Command, args []string) error { + // Note: The SDK doesn't have a ListRepoActionVariable method yet + return fmt.Errorf("listing variables is not yet supported in the SDK") +} + +func runActionsVariableGet(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, "") + 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 + } + + variableName := args[0] + + variable, _, err := client.GetRepoActionVariable(owner, name, variableName) + if err != nil { + return fmt.Errorf("failed to get variable: %w", err) + } + + fmt.Printf("%s=%s\n", variable.Name, variable.Value) + return nil +} + +func runActionsVariableCreate(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, "") + 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 + } + + variableName := args[0] + variableValue := args[1] + + _, err = client.CreateRepoActionVariable(owner, name, variableName, variableValue) + if err != nil { + return fmt.Errorf("failed to create variable: %w", err) + } + + fmt.Printf("Variable '%s' created successfully\n", variableName) + return nil +} + +func runActionsVariableUpdate(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, "") + 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 + } + + variableName := args[0] + variableValue := args[1] + + _, err = client.UpdateRepoActionVariable(owner, name, variableName, variableValue) + if err != nil { + return fmt.Errorf("failed to update variable: %w", err) + } + + fmt.Printf("Variable '%s' updated successfully\n", variableName) + return nil +} + +func runActionsVariableDelete(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, "") + 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 + } + + variableName := args[0] + + _, err = client.DeleteRepoActionVariable(owner, name, variableName) + if err != nil { + return fmt.Errorf("failed to delete variable: %w", err) + } + + fmt.Printf("Variable '%s' deleted successfully\n", variableName) + return nil +} diff --git a/go.mod b/go.mod index 9ba4c15..66a6f73 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,21 @@ module codeberg.org/romaintb/fgj -go 1.21 +go 1.23.0 require ( - code.gitea.io/sdk/gitea v0.18.0 + code.gitea.io/sdk/gitea v0.22.1 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 - golang.org/x/term v0.19.0 + golang.org/x/term v0.32.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/42wim/httpsig v1.2.3 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect - github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect @@ -29,9 +30,9 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.22.0 // indirect + golang.org/x/crypto v0.39.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sys v0.19.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index a6bd830..a28f421 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ -code.gitea.io/sdk/gitea v0.18.0 h1:+zZrwVmujIrgobt6wVBWCqITz6bn1aBjnCUHmpZrerI= -code.gitea.io/sdk/gitea v0.18.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI= +code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= +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/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= 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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -15,8 +17,8 @@ github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= -github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -61,8 +63,9 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= @@ -72,8 +75,8 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -81,15 +84,15 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= -golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/internal/api/client.go b/internal/api/client.go index 82c97da..6ea067e 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1,6 +1,11 @@ package api import ( + "encoding/json" + "fmt" + "io" + "net/http" + "code.gitea.io/sdk/gitea" "codeberg.org/romaintb/fgj/internal/config" ) @@ -8,6 +13,7 @@ import ( type Client struct { *gitea.Client hostname string + token string } func NewClient(hostname, token string) (*Client, error) { @@ -23,6 +29,7 @@ func NewClient(hostname, token string) (*Client, error) { return &Client{ Client: client, hostname: hostname, + token: token, }, nil } @@ -38,3 +45,42 @@ func NewClientFromConfig(cfg *config.Config, hostname string) (*Client, error) { func (c *Client) Hostname() string { return c.hostname } + +// GetJSON performs a GET request to the specified path and decodes the JSON response +func (c *Client) GetJSON(path string, result any) error { + baseURL := "https://" + c.hostname + url := baseURL + path + + req, err := http.NewRequest(http.MethodGet, url, nil) + 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") + + 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 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + return nil +}