From ed705adc707772998e0f9796243974cb5ed7859f Mon Sep 17 00:00:00 2001 From: Don Syme Date: Tue, 27 Jan 2026 02:32:34 +0000 Subject: [PATCH] various minor tweaks to ux --- pkg/cli/add_interactive.go | 83 +++++++++++++++++++++++------- pkg/cli/audit.go | 6 +-- pkg/cli/download_workflow.go | 18 +++---- pkg/cli/init.go | 3 +- pkg/cli/logs_download.go | 3 +- pkg/cli/logs_github_api.go | 6 +-- pkg/cli/pr_automerge.go | 12 ++--- pkg/cli/pr_command.go | 49 ++++-------------- pkg/cli/repo.go | 9 ++-- pkg/cli/run_interactive.go | 2 +- pkg/cli/run_workflow_validation.go | 3 +- pkg/cli/secrets.go | 3 +- pkg/cli/tokens_bootstrap.go | 3 +- pkg/cli/trial_command.go | 6 +-- pkg/cli/trial_repository.go | 12 ++--- pkg/cli/trial_support.go | 18 +++---- pkg/cli/update_actions.go | 6 +-- pkg/cli/update_extension_check.go | 6 +-- pkg/cli/update_git.go | 4 +- pkg/cli/update_workflows.go | 6 +-- pkg/console/spinner.go | 16 +++++- pkg/console/spinner_test.go | 9 ++-- pkg/workflow/github_cli.go | 46 +++++++++++++++++ 23 files changed, 184 insertions(+), 145 deletions(-) diff --git a/pkg/cli/add_interactive.go b/pkg/cli/add_interactive.go index 820f84bd348..0ff51e496f9 100644 --- a/pkg/cli/add_interactive.go +++ b/pkg/cli/add_interactive.go @@ -172,8 +172,7 @@ func (c *AddInteractiveConfig) showWorkflowDescriptions() { func (c *AddInteractiveConfig) checkGHAuthStatus() error { addInteractiveLog.Print("Checking GitHub CLI authentication status") - cmd := exec.Command("gh", "auth", "status") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Checking GitHub authentication...", "auth", "status") if err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage("You are not logged in to GitHub CLI.")) @@ -258,9 +257,7 @@ func (c *AddInteractiveConfig) checkRepoVisibility() bool { addInteractiveLog.Print("Checking repository visibility") // Use gh api to check repository visibility - args := []string{"api", fmt.Sprintf("/repos/%s", c.RepoOverride), "--jq", ".visibility"} - cmd := workflow.ExecGH(args...) - output, err := cmd.Output() + output, err := workflow.RunGH("Checking repository visibility...", "api", fmt.Sprintf("/repos/%s", c.RepoOverride), "--jq", ".visibility") if err != nil { addInteractiveLog.Printf("Could not check repository visibility: %v", err) // Default to public if we can't determine @@ -278,9 +275,7 @@ func (c *AddInteractiveConfig) checkActionsEnabled() error { addInteractiveLog.Print("Checking if GitHub Actions is enabled") // Use gh api to check Actions permissions - args := []string{"api", fmt.Sprintf("/repos/%s/actions/permissions", c.RepoOverride), "--jq", ".enabled"} - cmd := workflow.ExecGH(args...) - output, err := cmd.Output() + output, err := workflow.RunGH("Checking GitHub Actions status...", "api", fmt.Sprintf("/repos/%s/actions/permissions", c.RepoOverride), "--jq", ".enabled") if err != nil { addInteractiveLog.Printf("Failed to check Actions status: %v", err) // If we can't check, warn but continue - actual operations will fail if Actions is disabled @@ -349,9 +344,7 @@ func (c *AddInteractiveConfig) checkExistingSecrets() error { c.existingSecrets = make(map[string]bool) // Use gh api to list repository secrets - args := []string{"api", fmt.Sprintf("/repos/%s/actions/secrets", c.RepoOverride), "--jq", ".secrets[].name"} - cmd := workflow.ExecGH(args...) - output, err := cmd.Output() + output, err := workflow.RunGH("Checking repository secrets...", "api", fmt.Sprintf("/repos/%s/actions/secrets", c.RepoOverride), "--jq", ".secrets[].name") if err != nil { addInteractiveLog.Printf("Could not fetch existing secrets: %v", err) // Continue without error - we'll just assume no secrets exist @@ -709,7 +702,6 @@ func (c *AddInteractiveConfig) applyChanges(ctx context.Context, workflowFiles, addInteractiveLog.Print("Applying changes") fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, console.FormatProgressMessage("Creating pull request...")) // Add the workflow using existing implementation with --create-pull-request // Pass the resolved workflows to avoid re-fetching them @@ -722,8 +714,6 @@ func (c *AddInteractiveConfig) applyChanges(ctx context.Context, workflowFiles, c.addResult = result // Step 8b: Auto-merge the PR - fmt.Fprintln(os.Stderr, console.FormatProgressMessage("Merging pull request...")) - if result.PRNumber == 0 { fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Could not determine PR number")) fmt.Fprintln(os.Stderr, "Please merge the PR manually from the GitHub web interface.") @@ -764,13 +754,59 @@ func (c *AddInteractiveConfig) applyChanges(ctx context.Context, workflowFiles, fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Secret '%s' added", secretName))) } + // Step 8d: Update local branch with merged changes from GitHub + if err := c.updateLocalBranch(); err != nil { + // Non-fatal - warn but continue, workflow can still run on GitHub + addInteractiveLog.Printf("Failed to update local branch: %v", err) + if c.Verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not update local branch: %v", err))) + } + } + + return nil +} + +// updateLocalBranch fetches and pulls the latest changes from GitHub after PR merge +func (c *AddInteractiveConfig) updateLocalBranch() error { + addInteractiveLog.Print("Updating local branch with merged changes") + + // Get the default branch name using gh + output, err := workflow.RunGHCombined("Getting default branch...", "repo", "view", "--repo", c.RepoOverride, "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name") + defaultBranch := "main" + if err == nil { + defaultBranch = strings.TrimSpace(string(output)) + } + addInteractiveLog.Printf("Default branch: %s", defaultBranch) + + // Fetch the latest changes from origin + if c.Verbose { + fmt.Fprintln(os.Stderr, console.FormatProgressMessage("Fetching latest changes from GitHub...")) + } + + // Use git fetch followed by git pull + fetchCmd := exec.Command("git", "fetch", "origin", defaultBranch) + fetchOutput, err := fetchCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git fetch failed: %w (output: %s)", err, string(fetchOutput)) + } + + pullCmd := exec.Command("git", "pull", "origin", defaultBranch) + pullOutput, err := pullCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git pull failed: %w (output: %s)", err, string(pullOutput)) + } + + if c.Verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Local branch updated with merged changes")) + } + return nil } // mergePullRequest merges the specified PR func (c *AddInteractiveConfig) mergePullRequest(prNumber int) error { - cmd := workflow.ExecGH("pr", "merge", fmt.Sprintf("%d", prNumber), "--repo", c.RepoOverride, "--merge") - if output, err := cmd.CombinedOutput(); err != nil { + output, err := workflow.RunGHCombined("Merging pull request...", "pr", "merge", fmt.Sprintf("%d", prNumber), "--repo", c.RepoOverride, "--merge") + if err != nil { return fmt.Errorf("merge failed: %w (output: %s)", err, string(output)) } return nil @@ -778,8 +814,8 @@ func (c *AddInteractiveConfig) mergePullRequest(prNumber int) error { // addRepositorySecret adds a secret to the repository func (c *AddInteractiveConfig) addRepositorySecret(name, value string) error { - cmd := workflow.ExecGH("secret", "set", name, "--repo", c.RepoOverride, "--body", value) - if output, err := cmd.CombinedOutput(); err != nil { + output, err := workflow.RunGHCombined("Adding repository secret...", "secret", "set", name, "--repo", c.RepoOverride, "--body", value) + if err != nil { return fmt.Errorf("failed to set secret: %w (output: %s)", err, string(output)) } return nil @@ -934,8 +970,7 @@ func getWorkflowStatuses(pattern, repoOverride string, verbose bool) ([]Workflow fmt.Fprintf(os.Stderr, "Running: gh %s\n", strings.Join(args, " ")) } - cmd := workflow.ExecGH(args...) - output, err := cmd.Output() + output, err := workflow.RunGH("Checking workflow status...", args...) if err != nil { if verbose { fmt.Fprintf(os.Stderr, "gh workflow list failed: %v\n", err) @@ -972,6 +1007,14 @@ func (c *AddInteractiveConfig) showFinalInstructions() { fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("🎉 Addition complete!")) fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Fprintln(os.Stderr, "") + + // Show summary with workflow name(s) + if c.resolvedWorkflows != nil && len(c.resolvedWorkflows.Workflows) > 0 { + wf := c.resolvedWorkflows.Workflows[0] + fmt.Fprintf(os.Stderr, "The workflow '%s' has been added to the repository and will now run automatically.\n", wf.Spec.WorkflowName) + c.showWorkflowDescriptions() + } + fmt.Fprintln(os.Stderr, "Useful commands:") fmt.Fprintln(os.Stderr, console.FormatCommandMessage(fmt.Sprintf(" %s status # Check workflow status", string(constants.CLIExtensionPrefix)))) fmt.Fprintln(os.Stderr, console.FormatCommandMessage(fmt.Sprintf(" %s run # Trigger a workflow", string(constants.CLIExtensionPrefix)))) diff --git a/pkg/cli/audit.go b/pkg/cli/audit.go index 02bed62aeef..f3bee0b87d6 100644 --- a/pkg/cli/audit.go +++ b/pkg/cli/audit.go @@ -445,8 +445,7 @@ func auditJobRun(runID int64, jobID int64, stepNumber int, owner, repo, hostname fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Executing: gh %s", strings.Join(args, " ")))) } - cmd := workflow.ExecGH(args...) - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Fetching job logs...", args...) if err != nil { return fmt.Errorf("failed to fetch job logs: %w\nOutput: %s", err, string(output)) } @@ -622,8 +621,7 @@ func fetchWorkflowRunMetadata(runID int64, owner, repo, hostname string, verbose fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Executing: gh %s", strings.Join(args, " ")))) } - cmd := workflow.ExecGH(args...) - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Fetching run metadata...", args...) if err != nil { if verbose { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(string(output))) diff --git a/pkg/cli/download_workflow.go b/pkg/cli/download_workflow.go index bceb6699423..86686c8528f 100644 --- a/pkg/cli/download_workflow.go +++ b/pkg/cli/download_workflow.go @@ -133,8 +133,7 @@ func isBranchRefViaGit(repo, ref string) (bool, error) { //nolint:unused // Reserved for future use func isBranchRef(repo, ref string) (bool, error) { // Use gh CLI to list branches - cmd := workflow.ExecGH("api", fmt.Sprintf("/repos/%s/branches", repo), "--jq", ".[].name") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Fetching branches...", "api", fmt.Sprintf("/repos/%s/branches", repo), "--jq", ".[].name") if err != nil { // Check if this is an authentication error outputStr := string(output) @@ -205,8 +204,7 @@ func resolveBranchHead(repo, branch string, verbose bool) (string, error) { } // Use gh CLI to get branch info - cmd := workflow.ExecGH("api", fmt.Sprintf("/repos/%s/branches/%s", repo, branch), "--jq", ".commit.sha") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Fetching branch info...", "api", fmt.Sprintf("/repos/%s/branches/%s", repo, branch), "--jq", ".commit.sha") if err != nil { // Check if this is an authentication error outputStr := string(output) @@ -295,8 +293,7 @@ func resolveDefaultBranchHead(repo string, verbose bool) (string, error) { } // First get the default branch name - cmd := workflow.ExecGH("api", fmt.Sprintf("/repos/%s", repo), "--jq", ".default_branch") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Fetching repository info...", "api", fmt.Sprintf("/repos/%s", repo), "--jq", ".default_branch") if err != nil { // Check if this is an authentication error outputStr := string(output) @@ -458,8 +455,7 @@ func downloadWorkflowContent(repo, path, ref string, verbose bool) ([]byte, erro } // Use gh CLI to download the file - cmd := workflow.ExecGH("api", fmt.Sprintf("/repos/%s/contents/%s?ref=%s", repo, path, ref), "--jq", ".content") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Downloading workflow...", "api", fmt.Sprintf("/repos/%s/contents/%s?ref=%s", repo, path, ref), "--jq", ".content") if err != nil { // Check if this is an authentication error outputStr := string(output) @@ -477,9 +473,9 @@ func downloadWorkflowContent(repo, path, ref string, verbose bool) ([]byte, erro // The content is base64 encoded, decode it contentBase64 := strings.TrimSpace(string(output)) - cmd = exec.Command("base64", "-d") - cmd.Stdin = strings.NewReader(contentBase64) - content, err := cmd.Output() + base64Cmd := exec.Command("base64", "-d") + base64Cmd.Stdin = strings.NewReader(contentBase64) + content, err := base64Cmd.Output() if err != nil { return nil, fmt.Errorf("failed to decode file content: %w", err) } diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 2356f1f8776..cc3e68a7b9c 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -419,8 +419,7 @@ func attemptSetSecret(secretName, repoSlug string, verbose bool) error { } // Set the secret using gh CLI - cmd := workflow.ExecGH("secret", "set", secretName, "--repo", repoSlug, "--body", secretValue) - if output, err := cmd.CombinedOutput(); err != nil { + if output, err := workflow.RunGHCombined("Setting secret...", "secret", "set", secretName, "--repo", repoSlug, "--body", secretValue); err != nil { outputStr := string(output) // Check for permission-related errors if strings.Contains(outputStr, "403") || strings.Contains(outputStr, "Forbidden") || diff --git a/pkg/cli/logs_download.go b/pkg/cli/logs_download.go index 8d7caa60a81..422cc5ee4d0 100644 --- a/pkg/cli/logs_download.go +++ b/pkg/cli/logs_download.go @@ -299,8 +299,7 @@ func downloadWorkflowRunLogs(runID int64, outputDir string, verbose bool) error // Use gh api to download the logs zip file // The endpoint returns a 302 redirect to the actual zip file - cmd := workflow.ExecGH("api", "repos/{owner}/{repo}/actions/runs/"+strconv.FormatInt(runID, 10)+"/logs") - output, err := cmd.Output() + output, err := workflow.RunGH("Downloading workflow logs...", "api", "repos/{owner}/{repo}/actions/runs/"+strconv.FormatInt(runID, 10)+"/logs") if err != nil { // Check for authentication errors if strings.Contains(err.Error(), "exit status 4") { diff --git a/pkg/cli/logs_github_api.go b/pkg/cli/logs_github_api.go index 4e769d525fe..2712ab967b8 100644 --- a/pkg/cli/logs_github_api.go +++ b/pkg/cli/logs_github_api.go @@ -32,8 +32,7 @@ func fetchJobStatuses(runID int64, verbose bool) (int, error) { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching job statuses for run %d", runID))) } - cmd := workflow.ExecGH("api", fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d/jobs", runID), "--jq", ".jobs[] | {name: .name, status: .status, conclusion: .conclusion}") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Fetching job statuses...", "api", fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d/jobs", runID), "--jq", ".jobs[] | {name: .name, status: .status, conclusion: .conclusion}") if err != nil { if verbose { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Failed to fetch job statuses for run %d: %v", runID, err))) @@ -79,8 +78,7 @@ func fetchJobDetails(runID int64, verbose bool) ([]JobInfoWithDuration, error) { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching job details for run %d", runID))) } - cmd := workflow.ExecGH("api", fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d/jobs", runID), "--jq", ".jobs[] | {name: .name, status: .status, conclusion: .conclusion, started_at: .started_at, completed_at: .completed_at}") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Fetching job details...", "api", fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d/jobs", runID), "--jq", ".jobs[] | {name: .name, status: .status, conclusion: .conclusion, started_at: .started_at, completed_at: .completed_at}") if err != nil { if verbose { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Failed to fetch job details for run %d: %v", runID, err))) diff --git a/pkg/cli/pr_automerge.go b/pkg/cli/pr_automerge.go index b05c5c34a51..a0e6fc3929b 100644 --- a/pkg/cli/pr_automerge.go +++ b/pkg/cli/pr_automerge.go @@ -34,8 +34,7 @@ func AutoMergePullRequestsCreatedAfter(repoSlug string, createdAfter time.Time, } // List open PRs with creation time information - listCmd := workflow.ExecGH("pr", "list", "--repo", repoSlug, "--json", "number,title,isDraft,mergeable,createdAt,updatedAt") - output, err := listCmd.Output() + output, err := workflow.RunGH("Listing pull requests...", "pr", "list", "--repo", repoSlug, "--json", "number,title,isDraft,mergeable,createdAt,updatedAt") if err != nil { prAutomergeLog.Printf("Failed to list pull requests: %v", err) return fmt.Errorf("failed to list pull requests: %w", err) @@ -83,8 +82,7 @@ func AutoMergePullRequestsCreatedAfter(repoSlug string, createdAfter time.Time, // Convert from draft to non-draft if necessary if pr.IsDraft { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Converting PR #%d from draft to ready for review", pr.Number))) - readyCmd := workflow.ExecGH("pr", "ready", fmt.Sprintf("%d", pr.Number), "--repo", repoSlug) - if output, err := readyCmd.CombinedOutput(); err != nil { + if output, err := workflow.RunGHCombined("Converting draft to ready...", "pr", "ready", fmt.Sprintf("%d", pr.Number), "--repo", repoSlug); err != nil { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to convert PR #%d from draft: %v (output: %s)", pr.Number, err, string(output)))) continue } @@ -98,8 +96,7 @@ func AutoMergePullRequestsCreatedAfter(repoSlug string, createdAfter time.Time, // Auto-merge the PR fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Auto-merging PR #%d", pr.Number))) - mergeCmd := workflow.ExecGH("pr", "merge", fmt.Sprintf("%d", pr.Number), "--repo", repoSlug, "--auto", "--squash") - if output, err := mergeCmd.CombinedOutput(); err != nil { + if output, err := workflow.RunGHCombined("Auto-merging pull request...", "pr", "merge", fmt.Sprintf("%d", pr.Number), "--repo", repoSlug, "--auto", "--squash"); err != nil { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to auto-merge PR #%d: %v (output: %s)", pr.Number, err, string(output)))) continue } @@ -127,8 +124,7 @@ func WaitForWorkflowCompletion(repoSlug, runID string, timeoutMinutes int, verbo Timeout: timeout, PollFunc: func() (PollResult, error) { // Check workflow status - cmd := workflow.ExecGH("run", "view", runID, "--repo", repoSlug, "--json", "status,conclusion") - output, err := cmd.Output() + output, err := workflow.RunGH("Checking workflow status...", "run", "view", runID, "--repo", repoSlug, "--json", "status,conclusion") if err != nil { return PollFailure, fmt.Errorf("failed to check workflow status: %w", err) diff --git a/pkg/cli/pr_command.go b/pkg/cli/pr_command.go index 3c9216f9f1d..784295bde8c 100644 --- a/pkg/cli/pr_command.go +++ b/pkg/cli/pr_command.go @@ -112,8 +112,7 @@ func checkRepositoryAccess(owner, repo string) (bool, error) { prLog.Printf("Checking repository access: %s/%s", owner, repo) // Get current user - cmd := workflow.ExecGH("api", "/user", "--jq", ".login") - output, err := cmd.Output() + output, err := workflow.RunGH("Fetching user info...", "api", "/user", "--jq", ".login") if err != nil { prLog.Printf("Failed to get current user: %s", err) return false, fmt.Errorf("failed to get current user: %w", err) @@ -122,8 +121,7 @@ func checkRepositoryAccess(owner, repo string) (bool, error) { prLog.Printf("Current user: %s", username) // Check user's permission level for the repository - cmd = workflow.ExecGH("api", fmt.Sprintf("/repos/%s/%s/collaborators/%s/permission", owner, repo, username)) - output, err = cmd.Output() + output, err = workflow.RunGH("Checking repository permissions...", "api", fmt.Sprintf("/repos/%s/%s/collaborators/%s/permission", owner, repo, username)) if err != nil { // If we get an error, it likely means we don't have access or the repo doesn't exist prLog.Print("Repository access denied or repository not found") @@ -149,8 +147,7 @@ func checkRepositoryAccess(owner, repo string) (bool, error) { // createForkIfNeeded creates a fork of the target repository and returns the fork repo name func createForkIfNeeded(targetOwner, targetRepo string, verbose bool) (forkOwner, forkRepo string, err error) { // Get current user - cmd := workflow.ExecGH("api", "/user", "--jq", ".login") - output, err := cmd.Output() + output, err := workflow.RunGH("Fetching user info...", "api", "/user", "--jq", ".login") if err != nil { return "", "", fmt.Errorf("failed to get current user: %w", err) } @@ -167,12 +164,8 @@ func createForkIfNeeded(targetOwner, targetRepo string, verbose bool) (forkOwner } // Create fork - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Creating fork of %s/%s...", targetOwner, targetRepo))) - } - - forkCmd := workflow.ExecGH("repo", "fork", fmt.Sprintf("%s/%s", targetOwner, targetRepo), "--clone=false") - if err := forkCmd.Run(); err != nil { + _, err = workflow.RunGH(fmt.Sprintf("Creating fork of %s/%s...", targetOwner, targetRepo), "repo", "fork", fmt.Sprintf("%s/%s", targetOwner, targetRepo), "--clone=false") + if err != nil { return "", "", fmt.Errorf("failed to create fork: %w", err) } @@ -188,7 +181,7 @@ func fetchPRInfo(owner, repo string, prNumber int) (*PRInfo, error) { prLog.Printf("Fetching PR info: %s/%s#%d", owner, repo, prNumber) // Fetch PR details using gh API - cmd := workflow.ExecGH("api", fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, prNumber), + output, err := workflow.RunGH("Fetching pull request info...", "api", fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, prNumber), "--jq", `{ number: .number, title: .title, @@ -201,8 +194,6 @@ func fetchPRInfo(owner, repo string, prNumber int) (*PRInfo, error) { targetRepo: .base.repo.full_name, authorLogin: .user.login }`) - - output, err := cmd.Output() if err != nil { prLog.Printf("Failed to fetch PR info: %s", err) return nil, fmt.Errorf("failed to fetch PR info: %w", err) @@ -227,13 +218,8 @@ func createPatchFromPR(sourceOwner, sourceRepo string, prInfo *PRInfo, verbose b patchFile := filepath.Join(tempDir, "pr.patch") - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Creating patch using gh pr diff...")) - } - // Use gh pr diff command directly - this is the most reliable method - cmd := workflow.ExecGH("pr", "diff", fmt.Sprintf("%d", prInfo.Number), "--repo", fmt.Sprintf("%s/%s", sourceOwner, sourceRepo)) - diffContent, err := cmd.Output() + diffContent, err := workflow.RunGH("Fetching pull request diff...", "pr", "diff", fmt.Sprintf("%d", prInfo.Number), "--repo", fmt.Sprintf("%s/%s", sourceOwner, sourceRepo)) if err != nil { return "", fmt.Errorf("failed to get PR diff: %w", err) } @@ -284,12 +270,7 @@ func applyPatchToRepo(patchFile string, prInfo *PRInfo, targetOwner, targetRepo currentBranch := strings.TrimSpace(string(currentBranchOutput)) // Get the default branch of the target repository - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Getting default branch of target repository...")) - } - - defaultBranchCmd := workflow.ExecGH("api", fmt.Sprintf("/repos/%s/%s", targetOwner, targetRepo), "--jq", ".default_branch") - defaultBranchOutput, err := defaultBranchCmd.Output() + defaultBranchOutput, err := workflow.RunGH("Fetching default branch...", "api", fmt.Sprintf("/repos/%s/%s", targetOwner, targetRepo), "--jq", ".default_branch") if err != nil { return "", fmt.Errorf("failed to get default branch: %w", err) } @@ -530,10 +511,6 @@ func createTransferPR(targetOwner, targetRepo string, prInfo *PRInfo, branchName prBody += fmt.Sprintf("**Original Author:** @%s", prInfo.AuthorLogin) // Create the PR - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Creating pull request...")) - } - repoFlag := fmt.Sprintf("%s/%s", targetOwner, targetRepo) var headRef string if needsFork { @@ -542,13 +519,11 @@ func createTransferPR(targetOwner, targetRepo string, prInfo *PRInfo, branchName headRef = branchName } - cmd := workflow.ExecGH("pr", "create", + output, err := workflow.RunGH("Creating pull request...", "pr", "create", "--repo", repoFlag, "--title", prInfo.Title, "--body", prBody, "--head", headRef) - - output, err := cmd.Output() if err != nil { return fmt.Errorf("failed to create PR: %w", err) } @@ -796,8 +771,7 @@ func createPR(branchName, title, body string, verbose bool) (int, string, error) } // Get the current repository info to ensure PR is created in the correct repo - cmd := workflow.ExecGH("repo", "view", "--json", "owner,name") - repoOutput, err := cmd.Output() + repoOutput, err := workflow.RunGH("Fetching repository info...", "repo", "view", "--json", "owner,name") if err != nil { return 0, "", fmt.Errorf("failed to get current repository info: %w", err) } @@ -816,8 +790,7 @@ func createPR(branchName, title, body string, verbose bool) (int, string, error) repoSpec := fmt.Sprintf("%s/%s", repoInfo.Owner.Login, repoInfo.Name) // Explicitly specify the repository to ensure PR is created in the current repo (not upstream) - cmd = workflow.ExecGH("pr", "create", "--repo", repoSpec, "--title", title, "--body", body, "--head", branchName) - output, err := cmd.Output() + output, err := workflow.RunGH("Creating pull request...", "pr", "create", "--repo", repoSpec, "--title", title, "--body", body, "--head", branchName) if err != nil { // Try to get stderr for better error reporting if exitError, ok := err.(*exec.ExitError); ok { diff --git a/pkg/cli/repo.go b/pkg/cli/repo.go index 0bd7fbd2726..3d1428df7b9 100644 --- a/pkg/cli/repo.go +++ b/pkg/cli/repo.go @@ -35,8 +35,7 @@ func getCurrentRepoSlugUncached() (string, error) { // Try gh CLI first (most reliable) repoLog.Print("Attempting to get repository slug via gh CLI") - cmd := workflow.ExecGH("repo", "view", "--json", "owner,name", "--jq", ".owner.login + \"/\" + .name") - output, err := cmd.Output() + output, err := workflow.RunGH("Fetching repository info...", "repo", "view", "--json", "owner,name", "--jq", ".owner.login + \"/\" + .name") if err == nil { repoSlug := strings.TrimSpace(string(output)) if repoSlug != "" { @@ -51,14 +50,14 @@ func getCurrentRepoSlugUncached() (string, error) { // Fallback to git remote parsing if gh CLI is not available or fails repoLog.Print("gh CLI failed, falling back to git remote parsing") - cmd = exec.Command("git", "remote", "get-url", "origin") - output, err = cmd.Output() + gitCmd := exec.Command("git", "remote", "get-url", "origin") + gitOutput, err := gitCmd.Output() if err != nil { repoLog.Printf("Failed to get git remote URL: %v", err) return "", fmt.Errorf("failed to get current repository (gh CLI and git remote both failed): %w", err) } - remoteURL := strings.TrimSpace(string(output)) + remoteURL := strings.TrimSpace(string(gitOutput)) repoLog.Printf("Parsing git remote URL: %s", remoteURL) // Parse GitHub repository from remote URL diff --git a/pkg/cli/run_interactive.go b/pkg/cli/run_interactive.go index 9ef0ea3c390..c8d234db815 100644 --- a/pkg/cli/run_interactive.go +++ b/pkg/cli/run_interactive.go @@ -365,7 +365,7 @@ func RunSpecificWorkflowInteractively(ctx context.Context, workflowName string, fmt.Fprintln(os.Stderr, "") // Execute the workflow - err = RunWorkflowOnGitHub(ctx, workflowName, false, engineOverride, repoOverride, refOverride, autoMergePRs, pushSecrets, push, false, inputValues, verbose) + err = RunWorkflowOnGitHub(ctx, workflowName, false, engineOverride, repoOverride, refOverride, autoMergePRs, pushSecrets, push, true, inputValues, verbose) if err != nil { return fmt.Errorf("failed to run workflow: %w", err) } diff --git a/pkg/cli/run_workflow_validation.go b/pkg/cli/run_workflow_validation.go index bc8355eeb63..c2163f1d8ac 100644 --- a/pkg/cli/run_workflow_validation.go +++ b/pkg/cli/run_workflow_validation.go @@ -289,8 +289,7 @@ func validateRemoteWorkflow(workflowName string, repoOverride string, verbose bo } // Use gh CLI to list workflows in the target repository - cmd := workflow.ExecGH("workflow", "list", "--repo", repoOverride, "--json", "name,path,state") - output, err := cmd.Output() + output, err := workflow.RunGH("Listing workflows...", "workflow", "list", "--repo", repoOverride, "--json", "name,path,state") if err != nil { if exitError, ok := err.(*exec.ExitError); ok { return fmt.Errorf("failed to list workflows in repository '%s': %s", repoOverride, string(exitError.Stderr)) diff --git a/pkg/cli/secrets.go b/pkg/cli/secrets.go index 68e7ef2f321..092ed454d55 100644 --- a/pkg/cli/secrets.go +++ b/pkg/cli/secrets.go @@ -28,8 +28,7 @@ func checkSecretExists(secretName string) (bool, error) { secretsLog.Printf("Checking if secret exists: %s", secretName) // Use gh CLI to list repository secrets - cmd := workflow.ExecGH("secret", "list", "--json", "name") - output, err := cmd.Output() + output, err := workflow.RunGH("Listing secrets...", "secret", "list", "--json", "name") if err != nil { // Check if it's a 403 error by examining the error if exitError, ok := err.(*exec.ExitError); ok { diff --git a/pkg/cli/tokens_bootstrap.go b/pkg/cli/tokens_bootstrap.go index d9f03d5f960..bc14d463f9c 100644 --- a/pkg/cli/tokens_bootstrap.go +++ b/pkg/cli/tokens_bootstrap.go @@ -255,8 +255,7 @@ func checkSecretExistsInRepo(secretName, repoSlug string) (bool, error) { secretsLog.Printf("Checking if secret exists in %s: %s", repoSlug, secretName) // Use gh CLI to list repository secrets - cmd := workflow.ExecGH("secret", "list", "--repo", repoSlug, "--json", "name") - output, err := cmd.Output() + output, err := workflow.RunGH("Listing secrets...", "secret", "list", "--repo", repoSlug, "--json", "name") if err != nil { // Check if it's a 403 error by examining the error if exitError, ok := err.(*exec.ExitError); ok { diff --git a/pkg/cli/trial_command.go b/pkg/cli/trial_command.go index fb164102366..57124d4ff5e 100644 --- a/pkg/cli/trial_command.go +++ b/pkg/cli/trial_command.go @@ -566,8 +566,7 @@ func RunWorkflowTrials(workflowSpecs []string, opts TrialOptions) error { // getCurrentGitHubUsername gets the current GitHub username from gh CLI func getCurrentGitHubUsername() (string, error) { - cmd := workflow.ExecGH("api", "user", "--jq", ".login") - output, err := cmd.Output() + output, err := workflow.RunGH("Fetching GitHub username...", "api", "user", "--jq", ".login") if err != nil { return "", fmt.Errorf("failed to get GitHub username: %w", err) } @@ -828,8 +827,7 @@ func triggerWorkflowRun(repoSlug, workflowName string, triggerContext string, ve } } - cmd := workflow.ExecGH(args...) - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Triggering workflow...", args...) if err != nil { return "", fmt.Errorf("failed to trigger workflow run: %w (output: %s)", err, string(output)) diff --git a/pkg/cli/trial_repository.go b/pkg/cli/trial_repository.go index 2ceae1c32be..22112adef5c 100644 --- a/pkg/cli/trial_repository.go +++ b/pkg/cli/trial_repository.go @@ -40,8 +40,7 @@ func ensureTrialRepository(repoSlug string, cloneRepoSlug string, forceDeleteHos fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Force deleting existing host repository: %s", repoSlug))) } - deleteCmd := workflow.ExecGH("repo", "delete", repoSlug, "--yes") - if deleteOutput, deleteErr := deleteCmd.CombinedOutput(); deleteErr != nil { + if deleteOutput, deleteErr := workflow.RunGHCombined("Deleting repository...", "repo", "delete", repoSlug, "--yes"); deleteErr != nil { return fmt.Errorf("failed to force delete existing host repository %s: %w (output: %s)", repoSlug, deleteErr, string(deleteOutput)) } @@ -69,8 +68,7 @@ func ensureTrialRepository(repoSlug string, cloneRepoSlug string, forceDeleteHos } // Use gh CLI to create private repo with initial README using full OWNER/REPO format - cmd = workflow.ExecGH("repo", "create", repoSlug, "--private", "--add-readme", "--description", "GitHub Agentic Workflows host repository") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Creating repository...", "repo", "create", repoSlug, "--private", "--add-readme", "--description", "GitHub Agentic Workflows host repository") if err != nil { // Check if the error is because the repository already exists @@ -108,8 +106,7 @@ func ensureTrialRepository(repoSlug string, cloneRepoSlug string, forceDeleteHos fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Enabling discussions in repository: %s", repoSlug))) } - discussionsCmd := workflow.ExecGH("repo", "edit", repoSlug, "--enable-discussions") - if discussionsOutput, discussionsErr := discussionsCmd.CombinedOutput(); discussionsErr != nil { + if discussionsOutput, discussionsErr := workflow.RunGHCombined("Enabling discussions...", "repo", "edit", repoSlug, "--enable-discussions"); discussionsErr != nil { // Non-fatal error, just warn fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to enable discussions: %v (output: %s)", discussionsErr, string(discussionsOutput)))) } else if verbose { @@ -128,8 +125,7 @@ func cleanupTrialRepository(repoSlug string, verbose bool) error { } // Use gh CLI to delete the repository with proper username/repo format - cmd := workflow.ExecGH("repo", "delete", repoSlug, "--yes") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Deleting repository...", "repo", "delete", repoSlug, "--yes") if err != nil { return fmt.Errorf("failed to delete host repository: %w (output: %s)", err, string(output)) diff --git a/pkg/cli/trial_support.go b/pkg/cli/trial_support.go index 34c83f09860..9e17657f0dd 100644 --- a/pkg/cli/trial_support.go +++ b/pkg/cli/trial_support.go @@ -113,8 +113,7 @@ func determineAndAddEngineSecret(engineConfig *workflow.EngineConfig, hostRepoSl // addEngineSecret adds an engine-specific secret to the repository with tracking func addEngineSecret(secretName, hostRepoSlug string, tracker *TrialSecretTracker, verbose bool) error { // Check if secret already exists by trying to list secrets - listCmd := workflow.ExecGH("secret", "list", "--repo", hostRepoSlug) - listOutput, listErr := listCmd.CombinedOutput() + listOutput, listErr := workflow.RunGHCombined("Checking secrets...", "secret", "list", "--repo", hostRepoSlug) secretExists := listErr == nil && strings.Contains(string(listOutput), secretName) // Skip if secret already exists @@ -156,12 +155,11 @@ func addEngineSecret(secretName, hostRepoSlug string, tracker *TrialSecretTracke repoSlug := hostRepoSlug // Add the secret to the repository - addSecretCmd := workflow.ExecGH("secret", "set", secretName, "--repo", repoSlug, "--body", secretValue) if verbose { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Running: gh secret set %s --repo %s --body ", secretName, repoSlug))) } - if output, err := addSecretCmd.CombinedOutput(); err != nil { + if output, err := workflow.RunGHCombined("Adding secret...", "secret", "set", secretName, "--repo", repoSlug, "--body", secretValue); err != nil { return fmt.Errorf("failed to add %s secret: %w\nOutput: %s", secretName, err, string(output)) } @@ -187,8 +185,7 @@ func addGitHubTokenSecret(repoSlug string, tracker *TrialSecretTracker, verbose } // Check if secret already exists by trying to list secrets - listCmd := workflow.ExecGH("secret", "list", "--repo", repoSlug) - listOutput, listErr := listCmd.CombinedOutput() + listOutput, listErr := workflow.RunGHCombined("Checking secrets...", "secret", "list", "--repo", repoSlug) secretExists := listErr == nil && strings.Contains(string(listOutput), secretName) // Skip if secret already exists @@ -207,8 +204,7 @@ func addGitHubTokenSecret(repoSlug string, tracker *TrialSecretTracker, verbose } // Add the token as a repository secret - setCmd := workflow.ExecGH("secret", "set", secretName, "--repo", repoSlug, "--body", token) - output, err := setCmd.CombinedOutput() + output, err := workflow.RunGHCombined("Adding secret...", "secret", "set", secretName, "--repo", repoSlug, "--body", token) if err != nil { return fmt.Errorf("failed to set repository secret: %w (output: %s)", err, string(output)) @@ -243,8 +239,7 @@ func cleanupTrialSecrets(repoSlug string, tracker *TrialSecretTracker, verbose b secretsDeleted := 0 // Only delete secrets that were actually added by this trial command for secretName := range tracker.AddedSecrets { - cmd := workflow.ExecGH("secret", "delete", secretName, "--repo", repoSlug) - if output, err := cmd.CombinedOutput(); err != nil { + if output, err := workflow.RunGHCombined("Deleting secret...", "secret", "delete", secretName, "--repo", repoSlug); err != nil { // It's okay if the secret doesn't exist, just log in verbose mode if verbose && !strings.Contains(string(output), "Not Found") { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Could not delete secret %s: %s", secretName, string(output)))) @@ -289,8 +284,7 @@ func downloadAllArtifacts(hostRepoSlug, runID string, verbose bool) (*TrialArtif defer os.RemoveAll(tempDir) // Download all artifacts for this run - cmd := workflow.ExecGH("run", "download", runID, "--repo", repoSlug, "--dir", tempDir) - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Downloading artifacts...", "run", "download", runID, "--repo", repoSlug, "--dir", tempDir) if err != nil { // If no artifacts exist, that's okay - some workflows don't generate artifacts if verbose { diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index 026789cb985..3ff99a55ff9 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -162,8 +162,7 @@ func getLatestActionRelease(repo, currentVersion string, allowMajor, verbose boo updateLog.Printf("Using base repository: %s for action: %s", baseRepo, repo) // Use gh CLI to get releases - cmd := workflow.ExecGH("api", fmt.Sprintf("/repos/%s/releases", baseRepo), "--jq", ".[].tag_name") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Fetching releases...", "api", fmt.Sprintf("/repos/%s/releases", baseRepo), "--jq", ".[].tag_name") if err != nil { // Check if this is an authentication error outputStr := string(output) @@ -387,8 +386,7 @@ func getActionSHAForTag(repo, tag string) (string, error) { updateLog.Printf("Getting SHA for %s@%s", repo, tag) // Use gh CLI to get the git ref for the tag - cmd := workflow.ExecGH("api", fmt.Sprintf("/repos/%s/git/ref/tags/%s", repo, tag), "--jq", ".object.sha") - output, err := cmd.Output() + output, err := workflow.RunGH("Fetching tag info...", "api", fmt.Sprintf("/repos/%s/git/ref/tags/%s", repo, tag), "--jq", ".object.sha") if err != nil { return "", fmt.Errorf("failed to resolve tag: %w", err) } diff --git a/pkg/cli/update_extension_check.go b/pkg/cli/update_extension_check.go index 73393a218ba..d908194ea07 100644 --- a/pkg/cli/update_extension_check.go +++ b/pkg/cli/update_extension_check.go @@ -16,8 +16,7 @@ func checkExtensionUpdate(verbose bool) error { } // Run gh extension upgrade --dry-run to check for updates - cmd := workflow.ExecGH("extension", "upgrade", "githubnext/gh-aw", "--dry-run") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Checking for extension updates...", "extension", "upgrade", "githubnext/gh-aw", "--dry-run") if err != nil { if verbose { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to check for extension updates: %v", err))) @@ -71,8 +70,7 @@ func ensureLatestExtensionVersion(verbose bool) error { } // Run gh extension upgrade --dry-run to check for updates - cmd := workflow.ExecGH("extension", "upgrade", "githubnext/gh-aw", "--dry-run") - output, err := cmd.CombinedOutput() + output, err := workflow.RunGHCombined("Checking for extension updates...", "extension", "upgrade", "githubnext/gh-aw", "--dry-run") outputStr := strings.TrimSpace(string(output)) // Check for authentication errors (missing or invalid token) diff --git a/pkg/cli/update_git.go b/pkg/cli/update_git.go index 8dda59d920c..7261aabf5f4 100644 --- a/pkg/cli/update_git.go +++ b/pkg/cli/update_git.go @@ -79,11 +79,9 @@ func createUpdatePR(verbose bool) error { } // Create PR - cmd := workflow.ExecGH("pr", "create", + output, err := workflow.RunGHCombined("Creating pull request...", "pr", "create", "--title", "Update workflows and recompile", "--body", "This PR updates workflows from their source repositories and recompiles them.\n\nGenerated by `gh aw update --pr`") - - output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to create PR: %w\nOutput: %s", err, string(output)) } diff --git a/pkg/cli/update_workflows.go b/pkg/cli/update_workflows.go index 3556d68c7b3..d8531297ddd 100644 --- a/pkg/cli/update_workflows.go +++ b/pkg/cli/update_workflows.go @@ -177,8 +177,7 @@ func resolveLatestRef(repo, currentRef string, allowMajor, verbose bool) (string } // Get the latest commit SHA for the branch - cmd := workflow.ExecGH("api", fmt.Sprintf("/repos/%s/branches/%s", repo, currentRef), "--jq", ".commit.sha") - output, err := cmd.Output() + output, err := workflow.RunGH("Fetching branch info...", "api", fmt.Sprintf("/repos/%s/branches/%s", repo, currentRef), "--jq", ".commit.sha") if err != nil { return "", fmt.Errorf("failed to get latest commit for branch %s: %w", currentRef, err) } @@ -200,8 +199,7 @@ func resolveLatestRelease(repo, currentRef string, allowMajor, verbose bool) (st } // Get all releases using gh CLI - cmd := workflow.ExecGH("api", fmt.Sprintf("/repos/%s/releases", repo), "--jq", ".[].tag_name") - output, err := cmd.Output() + output, err := workflow.RunGH("Fetching releases...", "api", fmt.Sprintf("/repos/%s/releases", repo), "--jq", ".[].tag_name") if err != nil { return "", fmt.Errorf("failed to fetch releases: %w", err) } diff --git a/pkg/console/spinner.go b/pkg/console/spinner.go index 52af99ddd40..8a09996be75 100644 --- a/pkg/console/spinner.go +++ b/pkg/console/spinner.go @@ -50,19 +50,22 @@ import ( // updateMessageMsg is a custom message for updating the spinner message type updateMessageMsg string -// spinnerModel is the Bubble Tea model for the spinner +// spinnerModel is the Bubble Tea model for the spinner. +// Because we use tea.WithoutRenderer(), we must manually print in Update(). type spinnerModel struct { spinner spinner.Model message string + output *os.File } func (m spinnerModel) Init() tea.Cmd { return m.spinner.Tick } -func (m spinnerModel) View() string { return fmt.Sprintf("\r%s %s", m.spinner.View(), m.message) } +func (m spinnerModel) View() string { return "" } // Not used with WithoutRenderer func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case updateMessageMsg: m.message = string(msg) + m.render() return m, nil case tea.KeyMsg: if msg.String() == "ctrl+c" { @@ -71,11 +74,19 @@ func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) + m.render() return m, cmd } return m, nil } +// render manually prints the spinner frame (required when using WithoutRenderer) +func (m spinnerModel) render() { + if m.output != nil { + fmt.Fprintf(m.output, "\r\033[K%s %s", m.spinner.View(), m.message) + } +} + // SpinnerWrapper wraps the spinner functionality with TTY detection and Bubble Tea program type SpinnerWrapper struct { program *tea.Program @@ -94,6 +105,7 @@ func NewSpinner(message string) *SpinnerWrapper { model := spinnerModel{ spinner: spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(styles.Info)), message: message, + output: os.Stderr, } s.program = tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutRenderer()) } diff --git a/pkg/console/spinner_test.go b/pkg/console/spinner_test.go index c99006fc13f..7d8dd38c2c1 100644 --- a/pkg/console/spinner_test.go +++ b/pkg/console/spinner_test.go @@ -130,8 +130,10 @@ func TestSpinnerConcurrentAccess(t *testing.T) { func TestSpinnerBubbleTeaModel(t *testing.T) { // Test the Bubble Tea model directly + // Note: output is nil to prevent render() from printing during tests model := spinnerModel{ message: "Testing", + output: nil, } // Test Init returns a Cmd @@ -150,10 +152,11 @@ func TestSpinnerBubbleTeaModel(t *testing.T) { t.Error("Update should return spinnerModel") } - // Test View returns a string + // Note: View() returns empty string with WithoutRenderer() mode + // because rendering is done manually in Update() via render() view := model.View() - if view == "" { - t.Error("View should return a non-empty string") + if view != "" { + t.Errorf("View should return empty string with WithoutRenderer mode, got '%s'", view) } } diff --git a/pkg/workflow/github_cli.go b/pkg/workflow/github_cli.go index 9daaab7580d..5e8ed770ed2 100644 --- a/pkg/workflow/github_cli.go +++ b/pkg/workflow/github_cli.go @@ -7,7 +7,9 @@ import ( "os/exec" "github.com/cli/go-gh/v2" + "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/logger" + "github.com/githubnext/gh-aw/pkg/tty" ) var githubCLILog = logger.New("workflow:github_cli") @@ -90,3 +92,47 @@ func ExecGHWithOutput(args ...string) (stdout, stderr bytes.Buffer, err error) { githubCLILog.Printf("Executing gh CLI command via go-gh/v2: gh %v", args) return gh.Exec(args...) } + +// RunGH executes a gh CLI command with a spinner and returns the stdout output. +// The spinner is shown in interactive terminals to provide feedback during network operations. +// The spinnerMessage parameter describes what operation is being performed. +// +// Usage: +// +// output, err := RunGH("Fetching user info...", "api", "/user") +func RunGH(spinnerMessage string, args ...string) ([]byte, error) { + cmd := ExecGH(args...) + + // Show spinner in interactive terminals + if tty.IsStderrTerminal() { + spinner := console.NewSpinner(spinnerMessage) + spinner.Start() + output, err := cmd.Output() + spinner.Stop() + return output, err + } + + return cmd.Output() +} + +// RunGHCombined executes a gh CLI command with a spinner and returns combined stdout+stderr output. +// The spinner is shown in interactive terminals to provide feedback during network operations. +// Use this when you need to capture error messages from stderr. +// +// Usage: +// +// output, err := RunGHCombined("Creating repository...", "repo", "create", "myrepo") +func RunGHCombined(spinnerMessage string, args ...string) ([]byte, error) { + cmd := ExecGH(args...) + + // Show spinner in interactive terminals + if tty.IsStderrTerminal() { + spinner := console.NewSpinner(spinnerMessage) + spinner.Start() + output, err := cmd.CombinedOutput() + spinner.Stop() + return output, err + } + + return cmd.CombinedOutput() +}