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:
commit
65c686997f
14 changed files with 683 additions and 103 deletions
425
cmd/actions.go
425
cmd/actions.go
|
|
@ -2,8 +2,10 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -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 {
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||||
if content.Type == "file" && (content.Name == workflowIdentifier || content.Path == workflowIdentifier) {
|
|
||||||
workflow = &Workflow{
|
var latestRun *ActionRun
|
||||||
Name: content.Name,
|
|
||||||
Path: content.Path,
|
// Get the latest run for this workflow
|
||||||
State: "active",
|
runsEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?workflow_id=%s&limit=1", owner, name, workflow.Path)
|
||||||
}
|
var runList ActionRunList
|
||||||
break
|
if err := client.GetJSON(runsEndpoint, &runList); err == nil && len(runList.WorkflowRuns) > 0 {
|
||||||
}
|
latestRun = &runList.WorkflowRuns[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if workflow != nil {
|
if jsonOutput {
|
||||||
break
|
payload := struct {
|
||||||
|
Workflow *Workflow `json:"workflow"`
|
||||||
|
LatestRun *ActionRun `json:"latest_run,omitempty"`
|
||||||
|
}{
|
||||||
|
Workflow: workflow,
|
||||||
|
LatestRun: latestRun,
|
||||||
}
|
}
|
||||||
}
|
return writeJSON(payload)
|
||||||
|
|
||||||
if workflow == nil {
|
|
||||||
return fmt.Errorf("workflow '%s' not found", workflowIdentifier)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|
|
||||||
84
cmd/auth.go
84
cmd/auth.go
|
|
@ -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 {
|
||||||
|
|
@ -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
37
cmd/completion.go
Normal 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)
|
||||||
|
}
|
||||||
39
cmd/issue.go
39
cmd/issue.go
|
|
@ -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,18 +135,27 @@ 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()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -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
12
cmd/json.go
Normal 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
49
cmd/manpages.go
Normal 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")
|
||||||
|
}
|
||||||
13
cmd/pr.go
13
cmd/pr.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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
2
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue