From 7c0dcc8696c2a1b99a3cbc787421c7cc77da6085 Mon Sep 17 00:00:00 2001 From: sid Date: Sat, 21 Mar 2026 22:12:20 -0600 Subject: [PATCH 01/20] test: rewrite functional tests for full CLI coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate all tests into functional_test.go — remove duplicate new_commands_test.go. Replace SDK-only tests with actual CLI binary invocations. Add missing coverage for: issue list, issue view, issue comment, issue create, issue edit title, repo view, repo list, release view, --json flag on issue list/view and pr list. All tests now use -R flag consistently. 35 pass, 0 fail, 3 expected skips (pr view/diff need PRs, clone needs auth). --- tests/functional/fixtures.go | 37 + tests/functional/functional_test.go | 1464 ++++++++++++++++----------- 2 files changed, 892 insertions(+), 609 deletions(-) diff --git a/tests/functional/fixtures.go b/tests/functional/fixtures.go index 5423e5b..6bf9733 100644 --- a/tests/functional/fixtures.go +++ b/tests/functional/fixtures.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "code.gitea.io/sdk/gitea" @@ -295,3 +296,39 @@ func (env *TestEnv) RunCLI(args ...string) *CLIResult { return result } + +// runCLIWithStdin executes the CLI binary with the given args and pipes input to stdin. +func (env *TestEnv) runCLIWithStdin(input string, args ...string) *CLIResult { + cmd := exec.Command(env.GetBinaryPath(), args...) + cmd.Env = os.Environ() + cmd.Stdin = strings.NewReader(input) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + } + + result := &CLIResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + } + + env.T.Logf("Command: %s %v", env.GetBinaryPath(), args) + env.T.Logf("Exit code: %d", exitCode) + if stdout.Len() > 0 { + env.T.Logf("Stdout:\n%s", result.Stdout) + } + if stderr.Len() > 0 { + env.T.Logf("Stderr:\n%s", result.Stderr) + } + + return result +} diff --git a/tests/functional/functional_test.go b/tests/functional/functional_test.go index 3803e56..7202bb5 100644 --- a/tests/functional/functional_test.go +++ b/tests/functional/functional_test.go @@ -5,21 +5,23 @@ package functional import ( "bytes" + "encoding/json" "fmt" "os" + "strings" "testing" "time" "code.gitea.io/sdk/gitea" ) -// TestAPIConnection verifies we can connect to the Codeberg API with the test account +// ===== SDK API tests (verify test environment and API connectivity) ===== + func TestAPIConnection(t *testing.T) { env := NewTestEnv(t) env.VerifyAPIConnection() } -// TestListRepositories verifies we can list repositories func TestListRepositories(t *testing.T) { env := NewTestEnv(t) @@ -34,7 +36,6 @@ func TestListRepositories(t *testing.T) { t.Logf("Found %d repositories", len(repos)) - // Verify we can find our test repo found := false for _, repo := range repos { if repo.Name == env.RepoName { @@ -49,7 +50,6 @@ func TestListRepositories(t *testing.T) { } } -// TestGetRepository verifies we can get repository details func TestGetRepository(t *testing.T) { env := NewTestEnv(t) @@ -69,50 +69,165 @@ func TestGetRepository(t *testing.T) { t.Logf("Repository: %s (%s)", repo.FullName, repo.Description) } -// TestCreateAndListIssues verifies we can create and list issues -func TestCreateAndListIssues(t *testing.T) { +func TestAPIErrorHandling(t *testing.T) { env := NewTestEnv(t) - // Create a test issue - issueNum := env.CreateTestIssue( - "[FGJ E2E Test] Test Issue", - "This is a functional test issue for fgj", - ) - - defer env.CleanupIssue(issueNum) - - // List issues - issues, _, err := env.Client.ListRepoIssues(env.Owner, env.RepoName, gitea.ListIssueOption{}) - if err != nil { - t.Fatalf("failed to list issues: %v", err) + _, _, err := env.Client.GetIssue(env.Owner, env.RepoName, 999999) + if err == nil { + t.Fatalf("expected error for non-existent issue, got none") } - // Verify our created issue is in the list - found := false - for _, issue := range issues { - if issue.Index == issueNum { - found = true + t.Logf("Properly handled error: %v", err) +} + +// ===== CLI Issue Commands ===== + +func TestCLIIssueList(t *testing.T) { + env := NewTestEnv(t) + + // Create a test issue so the list is not empty + issueNum := env.CreateTestIssue("[FGJ E2E Test] Issue List", "For issue list test") + defer env.CleanupIssue(issueNum) + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "issue", "list", + ) + + if result.ExitCode != 0 { + t.Fatalf("issue list failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + if !strings.Contains(result.Stdout, "Issue List") { + t.Fatalf("expected issue list output to contain our test issue, got: %s", result.Stdout) + } + + t.Logf("Successfully listed issues via CLI") +} + +func TestCLIIssueListJSON(t *testing.T) { + env := NewTestEnv(t) + + issueNum := env.CreateTestIssue("[FGJ E2E Test] JSON List", "For JSON output test") + defer env.CleanupIssue(issueNum) + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "issue", "list", "--json", + ) + + if result.ExitCode != 0 { + t.Fatalf("issue list --json failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + var issues []interface{} + if err := json.Unmarshal([]byte(result.Stdout), &issues); err != nil { + t.Fatalf("failed to parse JSON output: %v\nOutput: %s", err, result.Stdout) + } + + if len(issues) == 0 { + t.Fatalf("expected at least one issue in JSON output") + } + + t.Logf("Successfully listed %d issues via CLI with --json", len(issues)) +} + +func TestCLIIssueView(t *testing.T) { + env := NewTestEnv(t) + + issueNum := env.CreateTestIssue("[FGJ E2E Test] View Test", "Testing issue view") + defer env.CleanupIssue(issueNum) + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "issue", "view", fmt.Sprintf("%d", issueNum), + ) + + if result.ExitCode != 0 { + t.Fatalf("issue view failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + if !strings.Contains(result.Stdout, "View Test") { + t.Fatalf("expected issue view output to contain title, got: %s", result.Stdout) + } + + t.Logf("Successfully viewed issue #%d via CLI", issueNum) +} + +func TestCLIIssueViewJSON(t *testing.T) { + env := NewTestEnv(t) + + issueNum := env.CreateTestIssue("[FGJ E2E Test] JSON View", "Testing JSON view") + defer env.CleanupIssue(issueNum) + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "issue", "view", fmt.Sprintf("%d", issueNum), "--json", + ) + + if result.ExitCode != 0 { + t.Fatalf("issue view --json failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + var data map[string]interface{} + if err := json.Unmarshal([]byte(result.Stdout), &data); err != nil { + t.Fatalf("failed to parse JSON output: %v\nOutput: %s", err, result.Stdout) + } + + // issue view --json wraps the issue in an "issue" key + issueObj, _ := data["issue"].(map[string]interface{}) + if issueObj == nil { + // fallback: maybe it's flat + issueObj = data + } + if title, ok := issueObj["title"].(string); !ok || !strings.Contains(title, "JSON View") { + t.Fatalf("expected title containing 'JSON View' in JSON output, got: %v", issueObj["title"]) + } + + t.Logf("Successfully viewed issue #%d via CLI with --json", issueNum) +} + +func TestCLIIssueCreate(t *testing.T) { + env := NewTestEnv(t) + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "issue", "create", + "-t", "[FGJ E2E Test] CLI Created Issue", + "-b", "Created directly via fgj CLI", + ) + + if result.ExitCode != 0 { + t.Fatalf("issue create failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + var issueNum int64 + for _, line := range strings.Split(strings.TrimSpace(result.Stdout), "\n") { + if _, err := fmt.Sscanf(line, "Issue created: #%d", &issueNum); err == nil { break } } - - if !found { - t.Fatalf("created issue #%d not found in issue list", issueNum) + if issueNum == 0 { + t.Fatalf("failed to parse issue number from output: %s", result.Stdout) } - t.Logf("Successfully created and listed issue #%d", issueNum) + defer env.CleanupIssue(issueNum) + t.Logf("Successfully created issue #%d via CLI", issueNum) } -// TestIssueCreateWithLabels verifies we can create issues with labels via CLI -func TestIssueCreateWithLabels(t *testing.T) { +func TestCLIIssueCreateWithLabels(t *testing.T) { env := NewTestEnv(t) - // Ensure test labels exist env.EnsureTestLabels() - // Create an issue with labels via CLI result := env.RunCLI( "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), "issue", "create", "-t", "[FGJ E2E Test] Issue with Labels", "-b", "This issue was created with labels", @@ -124,16 +239,18 @@ func TestIssueCreateWithLabels(t *testing.T) { t.Fatalf("issue create with labels failed with exit code %d: %s", result.ExitCode, result.Stderr) } - // Extract issue number from output (format: "Issue created: #") var issueNum int64 - _, err := fmt.Sscanf(result.Stdout, "Issue created: #%d", &issueNum) - if err != nil { - t.Fatalf("failed to parse issue number from output: %v", err) + for _, line := range strings.Split(strings.TrimSpace(result.Stdout), "\n") { + if _, err := fmt.Sscanf(line, "Issue created: #%d", &issueNum); err == nil { + break + } + } + if issueNum == 0 { + t.Fatalf("failed to parse issue number from output: %s", result.Stdout) } defer env.CleanupIssue(issueNum) - // Verify labels were applied labels, err := env.GetIssueLabels(issueNum) if err != nil { t.Fatalf("failed to get issue labels: %v", err) @@ -155,33 +272,161 @@ func TestIssueCreateWithLabels(t *testing.T) { t.Logf("Successfully created issue #%d with labels: bug, enhancement", issueNum) } -// TestIssueEditAddLabels verifies we can add labels to an issue via CLI -func TestIssueEditAddLabels(t *testing.T) { +func TestCLIIssueComment(t *testing.T) { env := NewTestEnv(t) - // Ensure test labels exist - env.EnsureTestLabels() - - // Create an issue without labels - issueNum := env.CreateTestIssue( - "[FGJ E2E Test] Add Labels Test", - "This issue will have labels added", - ) + issueNum := env.CreateTestIssue("[FGJ E2E Test] Comment Test", "Testing comment via CLI") defer env.CleanupIssue(issueNum) - // Verify no labels initially - labels, err := env.GetIssueLabels(issueNum) - if err != nil { - t.Fatalf("failed to get initial labels: %v", err) - } - - if len(labels) != 0 { - t.Fatalf("expected 0 labels initially, got %d", len(labels)) - } - - // Add labels via CLI result := env.RunCLI( "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "issue", "comment", + fmt.Sprintf("%d", issueNum), + "-b", "CLI comment from functional test", + ) + + if result.ExitCode != 0 { + t.Fatalf("issue comment failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + // Verify comment was added via API + comments, _, err := env.Client.ListIssueComments(env.Owner, env.RepoName, issueNum, gitea.ListIssueCommentOptions{}) + if err != nil { + t.Fatalf("failed to list comments: %v", err) + } + + found := false + for _, c := range comments { + if c.Body == "CLI comment from functional test" { + found = true + break + } + } + if !found { + t.Fatalf("comment not found on issue #%d", issueNum) + } + + t.Logf("Successfully commented on issue #%d via CLI", issueNum) +} + +func TestCLIIssueClose(t *testing.T) { + env := NewTestEnv(t) + + issueNum := env.CreateTestIssue("[FGJ E2E Test] Close Test", "Will be closed via CLI") + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "issue", "close", + fmt.Sprintf("%d", issueNum), + ) + + if result.ExitCode != 0 { + t.Fatalf("issue close failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + + if issue.State != "closed" { + t.Fatalf("expected issue state 'closed', got '%s'", issue.State) + } + + t.Logf("Successfully closed issue #%d via CLI", issueNum) +} + +func TestCLIIssueCloseWithComment(t *testing.T) { + env := NewTestEnv(t) + + issueNum := env.CreateTestIssue("[FGJ E2E Test] Close with comment", "Will be closed with a comment") + + commentText := "Fixed in v2.0 - closing via functional test" + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "issue", "close", + fmt.Sprintf("%d", issueNum), + "-c", commentText, + ) + + if result.ExitCode != 0 { + t.Fatalf("issue close -c failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + + if issue.State != "closed" { + t.Fatalf("expected issue state 'closed', got '%s'", issue.State) + } + + comments, _, err := env.Client.ListIssueComments(env.Owner, env.RepoName, issueNum, gitea.ListIssueCommentOptions{}) + if err != nil { + t.Fatalf("failed to list issue comments: %v", err) + } + + found := false + for _, comment := range comments { + if comment.Body == commentText { + found = true + break + } + } + + if !found { + t.Fatalf("comment '%s' not found in issue comments", commentText) + } + + t.Logf("Successfully closed issue #%d with comment via CLI", issueNum) +} + +func TestCLIIssueEditTitle(t *testing.T) { + env := NewTestEnv(t) + + issueNum := env.CreateTestIssue("[FGJ E2E Test] Original Title", "Will be edited") + defer env.CleanupIssue(issueNum) + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "issue", "edit", + fmt.Sprintf("%d", issueNum), + "-t", "[FGJ E2E Test] Updated Title", + ) + + if result.ExitCode != 0 { + t.Fatalf("issue edit failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) + if err != nil { + t.Fatalf("failed to get issue: %v", err) + } + + if issue.Title != "[FGJ E2E Test] Updated Title" { + t.Fatalf("expected updated title, got '%s'", issue.Title) + } + + t.Logf("Successfully edited issue #%d title via CLI", issueNum) +} + +func TestCLIIssueEditAddLabels(t *testing.T) { + env := NewTestEnv(t) + + env.EnsureTestLabels() + + issueNum := env.CreateTestIssue("[FGJ E2E Test] Add Labels", "Will have labels added") + defer env.CleanupIssue(issueNum) + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), "issue", "edit", fmt.Sprintf("%d", issueNum), "--add-label", "bug", @@ -192,8 +437,7 @@ func TestIssueEditAddLabels(t *testing.T) { t.Fatalf("issue edit add-label failed with exit code %d: %s", result.ExitCode, result.Stderr) } - // Verify labels were added - labels, err = env.GetIssueLabels(issueNum) + labels, err := env.GetIssueLabels(issueNum) if err != nil { t.Fatalf("failed to get labels after edit: %v", err) } @@ -211,27 +455,23 @@ func TestIssueEditAddLabels(t *testing.T) { t.Fatalf("expected labels 'bug' and 'help-wanted', got %v", labelNames) } - t.Logf("Successfully added labels to issue #%d", issueNum) + t.Logf("Successfully added labels to issue #%d via CLI", issueNum) } -// TestIssueEditRemoveLabels verifies we can remove labels from an issue via CLI -func TestIssueEditRemoveLabels(t *testing.T) { +func TestCLIIssueEditRemoveLabels(t *testing.T) { env := NewTestEnv(t) - // Ensure test labels exist env.EnsureTestLabels() - // Create an issue with labels issue, _, err := env.Client.CreateIssue(env.Owner, env.RepoName, gitea.CreateIssueOption{ - Title: "[FGJ E2E Test] Remove Labels Test", - Body: "This issue will have labels removed", + Title: "[FGJ E2E Test] Remove Labels", + Body: "Will have labels removed", }) if err != nil { t.Fatalf("failed to create issue: %v", err) } defer env.CleanupIssue(issue.Index) - // Add labels via API labelIDs := env.GetLabelIDs([]string{"bug", "enhancement"}) _, _, err = env.Client.AddIssueLabels(env.Owner, env.RepoName, issue.Index, gitea.IssueLabelsOption{ Labels: labelIDs, @@ -240,19 +480,9 @@ func TestIssueEditRemoveLabels(t *testing.T) { t.Fatalf("failed to add initial labels: %v", err) } - // Verify initial labels - labels, err := env.GetIssueLabels(issue.Index) - if err != nil { - t.Fatalf("failed to get initial labels: %v", err) - } - - if len(labels) != 2 { - t.Fatalf("expected 2 labels initially, got %d", len(labels)) - } - - // Remove one label via CLI result := env.RunCLI( "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), "issue", "edit", fmt.Sprintf("%d", issue.Index), "--remove-label", "bug", @@ -262,8 +492,7 @@ func TestIssueEditRemoveLabels(t *testing.T) { t.Fatalf("issue edit remove-label failed with exit code %d: %s", result.ExitCode, result.Stderr) } - // Verify label was removed - labels, err = env.GetIssueLabels(issue.Index) + labels, err := env.GetIssueLabels(issue.Index) if err != nil { t.Fatalf("failed to get labels after edit: %v", err) } @@ -276,455 +505,17 @@ func TestIssueEditRemoveLabels(t *testing.T) { t.Fatalf("expected remaining label 'enhancement', got '%s'", labels[0].Name) } - t.Logf("Successfully removed label from issue #%d", issue.Index) + t.Logf("Successfully removed label from issue #%d via CLI", issue.Index) } -// TestGetIssue verifies we can get issue details -func TestGetIssue(t *testing.T) { - env := NewTestEnv(t) +// ===== CLI PR Commands ===== - // Create a test issue - issueNum := env.CreateTestIssue( - "[FGJ E2E Test] Get Issue Test", - "Testing get issue functionality", - ) - - defer env.CleanupIssue(issueNum) - - // Get the issue - issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) - if err != nil { - t.Fatalf("failed to get issue: %v", err) - } - - if issue.Index != issueNum { - t.Fatalf("expected issue number %d, got %d", issueNum, issue.Index) - } - - if issue.Title != "[FGJ E2E Test] Get Issue Test" { - t.Fatalf("issue title mismatch") - } - - t.Logf("Retrieved issue #%d: %s", issueNum, issue.Title) -} - -// TestCommentOnIssue verifies we can comment on issues -func TestCommentOnIssue(t *testing.T) { - env := NewTestEnv(t) - - // Create a test issue - issueNum := env.CreateTestIssue( - "[FGJ E2E Test] Comment Test", - "Testing comment functionality", - ) - - defer env.CleanupIssue(issueNum) - - // Add a comment - comment, _, err := env.Client.CreateIssueComment( - env.Owner, - env.RepoName, - issueNum, - gitea.CreateIssueCommentOption{ - Body: "This is an automated test comment from fgj functional tests", - }, - ) - if err != nil { - t.Fatalf("failed to create issue comment: %v", err) - } - - if comment.Body != "This is an automated test comment from fgj functional tests" { - t.Fatalf("comment body mismatch") - } - - t.Logf("Successfully added comment to issue #%d", issueNum) -} - -// TestGetRepositoryWithCollaborators verifies we can get repo and check collaborators -func TestGetRepositoryWithCollaborators(t *testing.T) { - env := NewTestEnv(t) - - // Get repository with full details - repo, _, err := env.Client.GetRepo(env.Owner, env.RepoName) - if err != nil { - t.Fatalf("failed to get repository: %v", err) - } - - // Verify basic repo properties - if repo.FullName != env.Owner+"/"+env.RepoName { - t.Fatalf("expected repo full name %s/%s, got %s", env.Owner, env.RepoName, repo.FullName) - } - - t.Logf("Repository %s has %d watchers, %d stars, %d forks", - repo.FullName, repo.Watchers, repo.Stars, repo.Forks) -} - -// TestAPIErrorHandling verifies proper error handling for invalid requests -func TestAPIErrorHandling(t *testing.T) { - env := NewTestEnv(t) - - // Try to get a non-existent issue - _, _, err := env.Client.GetIssue(env.Owner, env.RepoName, 999999) - if err == nil { - t.Fatalf("expected error for non-existent issue, got none") - } - - t.Logf("Properly handled error: %v", err) -} - -// TestRepositoryExists verifies the test repository exists and is accessible -func TestRepositoryExists(t *testing.T) { - env := NewTestEnv(t) - - repo, _, err := env.Client.GetRepo(env.Owner, env.RepoName) - if err != nil { - t.Fatalf("test repository does not exist or is not accessible: %v", err) - } - - if repo.Private { - t.Logf("Test repository is private: %s", repo.FullName) - } else { - t.Logf("Test repository is public: %s", repo.FullName) - } -} - -// ===== CLI COMMAND TESTS ===== - -// TestCLIIssueCreate verifies the `fgj issue create` command works (via API, not CLI) -// Note: CLI requires proper config setup which is tested via API tests -func TestCLIIssueCreate(t *testing.T) { - env := NewTestEnv(t) - - // Use API directly since CLI requires config file for auth - issueNum := env.CreateTestIssue( - "[FGJ CLI Test] Created via API", - "This issue was created using the API (CLI test proxy)", - ) - defer env.CleanupIssue(issueNum) - - // Verify the issue was created - issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) - if err != nil { - t.Fatalf("failed to get created issue: %v", err) - } - - if issue.Title != "[FGJ CLI Test] Created via API" { - t.Fatalf("issue title mismatch") - } - - t.Logf("Successfully tested issue create via API for CLI #%d", issueNum) -} - -// TestCLIIssueComment verifies the `fgj issue comment` command works (via API, not CLI) -// Note: CLI requires proper config setup which is tested via API tests -func TestCLIIssueComment(t *testing.T) { - env := NewTestEnv(t) - - // Create an issue via API - issueNum := env.CreateTestIssue( - "[FGJ CLI Test] For commenting", - "This issue will receive a comment", - ) - defer env.CleanupIssue(issueNum) - - // Add comment via API - comment, _, err := env.Client.CreateIssueComment( - env.Owner, - env.RepoName, - issueNum, - gitea.CreateIssueCommentOption{ - Body: "This is a test comment", - }, - ) - if err != nil { - t.Fatalf("failed to create comment: %v", err) - } - - if comment.Body != "This is a test comment" { - t.Fatalf("comment body mismatch") - } - - t.Logf("Successfully tested issue comment via API for CLI #%d", issueNum) -} - -// TestCLIIssueClose verifies the `fgj issue close` command works (via API, not CLI) -// Note: CLI requires proper config setup which is tested via API tests -func TestCLIIssueClose(t *testing.T) { - env := NewTestEnv(t) - - // Create an issue via API - issueNum := env.CreateTestIssue( - "[FGJ CLI Test] For closing", - "This issue will be closed", - ) - - // Close via API - closeState := gitea.StateClosed - _, _, err := env.Client.EditIssue(env.Owner, env.RepoName, issueNum, gitea.EditIssueOption{ - State: &closeState, - }) - if err != nil { - t.Fatalf("failed to close issue: %v", err) - } - - // Verify the issue was closed - issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) - if err != nil { - t.Fatalf("failed to get issue: %v", err) - } - - if issue.State != "closed" { - t.Fatalf("expected issue state 'closed', got '%s'", issue.State) - } - - t.Logf("Successfully tested issue close via API for CLI #%d", issueNum) -} - -// TestCLIIssueCloseWithComment verifies the `fgj issue close -c` command works -func TestCLIIssueCloseWithComment(t *testing.T) { - env := NewTestEnv(t) - - // Create an issue via API - issueNum := env.CreateTestIssue( - "[FGJ CLI Test] Close with comment", - "This issue will be closed with a comment in one command", - ) - - commentText := "Fixed in v2.0 - closing via functional test" - - // Close with comment via CLI - result := env.RunCLI( - "--hostname", env.Hostname, - "issue", "close", - fmt.Sprintf("%d", issueNum), - "-c", commentText, - ) - - if result.ExitCode != 0 { - t.Fatalf("issue close -c failed with exit code %d: %s", result.ExitCode, result.Stderr) - } - - // Verify the issue was closed - issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) - if err != nil { - t.Fatalf("failed to get issue: %v", err) - } - - if issue.State != "closed" { - t.Fatalf("expected issue state 'closed', got '%s'", issue.State) - } - - // Verify the comment was added - comments, _, err := env.Client.ListIssueComments(env.Owner, env.RepoName, issueNum, gitea.ListIssueCommentOptions{}) - if err != nil { - t.Fatalf("failed to list issue comments: %v", err) - } - - if len(comments) == 0 { - t.Fatalf("expected at least one comment, got none") - } - - // Find our comment - found := false - for _, comment := range comments { - if comment.Body == commentText { - found = true - break - } - } - - if !found { - t.Fatalf("comment '%s' not found in issue comments", commentText) - } - - t.Logf("Successfully tested issue close with comment via CLI #%d", issueNum) -} - -// TestEditIssueTitle verifies we can edit an issue's title -func TestEditIssueTitle(t *testing.T) { - env := NewTestEnv(t) - - // Create a test issue - issueNum := env.CreateTestIssue( - "[FGJ E2E Test] Original Title", - "This issue's title will be edited", - ) - - defer env.CleanupIssue(issueNum) - - // Edit the title - newTitle := "[FGJ E2E Test] Updated Title" - _, _, err := env.Client.EditIssue(env.Owner, env.RepoName, issueNum, gitea.EditIssueOption{ - Title: newTitle, - }) - if err != nil { - t.Fatalf("failed to edit issue title: %v", err) - } - - // Verify the title was updated - issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) - if err != nil { - t.Fatalf("failed to get issue: %v", err) - } - - if issue.Title != newTitle { - t.Fatalf("expected title '%s', got '%s'", newTitle, issue.Title) - } - - t.Logf("Successfully edited issue #%d title", issueNum) -} - -// TestEditIssueBody verifies we can edit an issue's body -func TestEditIssueBody(t *testing.T) { - env := NewTestEnv(t) - - // Create a test issue - issueNum := env.CreateTestIssue( - "[FGJ E2E Test] Edit Body Test", - "Original body content", - ) - - defer env.CleanupIssue(issueNum) - - // Edit the body - newBody := "Updated body content from functional test" - _, _, err := env.Client.EditIssue(env.Owner, env.RepoName, issueNum, gitea.EditIssueOption{ - Body: &newBody, - }) - if err != nil { - t.Fatalf("failed to edit issue body: %v", err) - } - - // Verify the body was updated - issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) - if err != nil { - t.Fatalf("failed to get issue: %v", err) - } - - if issue.Body != newBody { - t.Fatalf("expected body '%s', got '%s'", newBody, issue.Body) - } - - t.Logf("Successfully edited issue #%d body", issueNum) -} - -// TestEditIssueState verifies we can edit an issue's state -func TestEditIssueState(t *testing.T) { - env := NewTestEnv(t) - - // Create a test issue (starts as open) - issueNum := env.CreateTestIssue( - "[FGJ E2E Test] Edit State Test", - "This issue's state will be changed", - ) - - defer env.CleanupIssue(issueNum) - - // Verify it starts as open - issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) - if err != nil { - t.Fatalf("failed to get issue: %v", err) - } - - if issue.State != "open" { - t.Fatalf("expected initial state 'open', got '%s'", issue.State) - } - - // Edit state to closed - closedState := gitea.StateClosed - _, _, err = env.Client.EditIssue(env.Owner, env.RepoName, issueNum, gitea.EditIssueOption{ - State: &closedState, - }) - if err != nil { - t.Fatalf("failed to edit issue state: %v", err) - } - - // Verify state changed to closed - issue, _, err = env.Client.GetIssue(env.Owner, env.RepoName, issueNum) - if err != nil { - t.Fatalf("failed to get issue: %v", err) - } - - if issue.State != "closed" { - t.Fatalf("expected state 'closed' after edit, got '%s'", issue.State) - } - - // Edit state back to open - openState := gitea.StateOpen - _, _, err = env.Client.EditIssue(env.Owner, env.RepoName, issueNum, gitea.EditIssueOption{ - State: &openState, - }) - if err != nil { - t.Fatalf("failed to reopen issue: %v", err) - } - - // Verify state is now open - issue, _, err = env.Client.GetIssue(env.Owner, env.RepoName, issueNum) - if err != nil { - t.Fatalf("failed to get issue: %v", err) - } - - if issue.State != "open" { - t.Fatalf("expected state 'open' after reopen, got '%s'", issue.State) - } - - t.Logf("Successfully edited issue #%d state", issueNum) -} - -// TestEditIssueMultipleFields verifies we can edit multiple issue fields at once -func TestEditIssueMultipleFields(t *testing.T) { - env := NewTestEnv(t) - - // Create a test issue - issueNum := env.CreateTestIssue( - "[FGJ E2E Test] Original Multi-Edit", - "Original body for multi-field edit", - ) - - defer env.CleanupIssue(issueNum) - - // Edit title, body, and state at once - newTitle := "[FGJ E2E Test] Updated Multi-Edit" - newBody := "Updated body from multi-field edit test" - closedState := gitea.StateClosed - - _, _, err := env.Client.EditIssue(env.Owner, env.RepoName, issueNum, gitea.EditIssueOption{ - Title: newTitle, - Body: &newBody, - State: &closedState, - }) - if err != nil { - t.Fatalf("failed to edit multiple issue fields: %v", err) - } - - // Verify all fields were updated - issue, _, err := env.Client.GetIssue(env.Owner, env.RepoName, issueNum) - if err != nil { - t.Fatalf("failed to get issue: %v", err) - } - - if issue.Title != newTitle { - t.Fatalf("expected title '%s', got '%s'", newTitle, issue.Title) - } - - if issue.Body != newBody { - t.Fatalf("expected body '%s', got '%s'", newBody, issue.Body) - } - - if issue.State != "closed" { - t.Fatalf("expected state 'closed', got '%s'", issue.State) - } - - t.Logf("Successfully edited multiple fields on issue #%d", issueNum) -} - -// TestCLIPRList verifies the `fgj pr list` command works func TestCLIPRList(t *testing.T) { env := NewTestEnv(t) - // Run: fgj pr list result := env.RunCLI( "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), "pr", "list", ) @@ -732,72 +523,47 @@ func TestCLIPRList(t *testing.T) { t.Fatalf("pr list failed with exit code %d: %s", result.ExitCode, result.Stderr) } - // pr list should produce output (even if empty) - if result.Stdout == "" { - t.Logf("Note: pr list produced empty output (no PRs in repo)") - } else { - t.Logf("pr list output:\n%s", result.Stdout) - } - - // Verify we got some output (at least the header or "No pull requests" message) - if result.Stdout == "" && result.Stderr == "" { - t.Logf("Note: pr list produced no output") - } - t.Logf("Successfully listed pull requests via CLI") } -// TestCLIActionsRunList verifies the `fgj actions run list` command works -func TestCLIActionsRunList(t *testing.T) { +func TestCLIPRListJSON(t *testing.T) { env := NewTestEnv(t) - // Run: fgj actions run list result := env.RunCLI( "--hostname", env.Hostname, - "actions", "run", "list", + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "pr", "list", "--json", ) - // Actions might not be enabled, so we accept both success and failure if result.ExitCode != 0 { - if bytes.Contains([]byte(result.Stderr), []byte("Actions")) || - bytes.Contains([]byte(result.Stderr), []byte("not enabled")) || - bytes.Contains([]byte(result.Stderr), []byte("404")) { - t.Logf("Note: actions run list not available (Actions may not be enabled): %s", result.Stderr) - return - } - t.Logf("actions run list exited with code %d: %s", result.ExitCode, result.Stderr) + t.Fatalf("pr list --json failed with exit code %d: %s", result.ExitCode, result.Stderr) } - if result.Stdout == "" { - t.Logf("Note: actions run list produced empty output (no workflow runs)") - } else { - t.Logf("actions run list output:\n%s", result.Stdout) + var prs []interface{} + if err := json.Unmarshal([]byte(result.Stdout), &prs); err != nil { + t.Fatalf("failed to parse JSON output: %v\nOutput: %s", err, result.Stdout) } - t.Logf("Successfully listed workflow runs via CLI") + t.Logf("Successfully listed %d PRs via CLI with --json", len(prs)) } -// TestCLIPRView verifies the `fgj pr view` command works func TestCLIPRView(t *testing.T) { env := NewTestEnv(t) - // First, check if there are any PRs in the repository prs, err := env.ListPullRequests() if err != nil { t.Fatalf("failed to list PRs: %v", err) } if len(prs) == 0 { - t.Logf("Note: No pull requests in test repository, skipping pr view test") t.Skip("No PRs available to view") } - // Get the first PR number prNumber := prs[0].Index - // Run: fgj pr view result := env.RunCLI( "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), "pr", "view", fmt.Sprintf("%d", prNumber), ) @@ -810,56 +576,226 @@ func TestCLIPRView(t *testing.T) { t.Fatalf("pr view produced no output") } - // Verify output contains PR information - if !bytes.Contains([]byte(result.Stdout), []byte(prs[0].Title)) && - !bytes.Contains([]byte(result.Stdout), []byte(fmt.Sprintf("#%d", prNumber))) { - t.Logf("Warning: pr view output may not contain expected PR info") - t.Logf("Output: %s", result.Stdout) - } - t.Logf("Successfully viewed PR #%d via CLI", prNumber) } -// TestCLIRepoClone verifies the `fgj repo clone` command works +func TestCLIPRDiff(t *testing.T) { + env := NewTestEnv(t) + + prs, err := env.ListPullRequests() + if err != nil { + t.Fatalf("failed to list PRs: %v", err) + } + + if len(prs) == 0 { + t.Skip("No PRs available for diff test") + } + + prNumber := prs[0].Index + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "pr", "diff", + fmt.Sprintf("%d", prNumber), + "--name-only", + ) + + if result.ExitCode != 0 { + t.Fatalf("pr diff failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + if strings.TrimSpace(result.Stdout) == "" { + t.Fatalf("pr diff --name-only produced no output") + } + + t.Logf("Successfully retrieved diff for PR #%d via CLI", prNumber) +} + +func TestCLIPRComment(t *testing.T) { + env := NewTestEnv(t) + + // PRs share the comment API with issues + issueNum := env.CreateTestIssue("[FGJ E2E Test] PR Comment Test", "Testing pr comment command") + defer env.CleanupIssue(issueNum) + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "pr", "comment", + fmt.Sprintf("%d", issueNum), + "-b", "Automated test comment via fgj pr comment", + ) + + if result.ExitCode != 0 { + t.Fatalf("pr comment failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + t.Logf("Successfully commented on issue #%d via fgj pr comment", issueNum) +} + +// ===== CLI Repo Commands ===== + +func TestCLIRepoView(t *testing.T) { + env := NewTestEnv(t) + + result := env.RunCLI( + "--hostname", env.Hostname, + "repo", "view", + fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + ) + + if result.ExitCode != 0 { + t.Fatalf("repo view failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + if !strings.Contains(result.Stdout, env.RepoName) { + t.Fatalf("expected repo name in output, got: %s", result.Stdout) + } + + t.Logf("Successfully viewed repo via CLI") +} + +func TestCLIRepoList(t *testing.T) { + env := NewTestEnv(t) + + result := env.RunCLI( + "--hostname", env.Hostname, + "repo", "list", + ) + + if result.ExitCode != 0 { + t.Fatalf("repo list failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + if !strings.Contains(result.Stdout, env.RepoName) { + t.Fatalf("expected test repo in list output, got: %s", result.Stdout) + } + + t.Logf("Successfully listed repos via CLI") +} + +func TestCLIRepoCreate(t *testing.T) { + env := NewTestEnv(t) + + repoName := fmt.Sprintf("fgj-test-create-%d", time.Now().UnixNano()) + defer env.CleanupRepo(env.Owner, repoName) + + result := env.RunCLI( + "--hostname", env.Hostname, + "repo", "create", repoName, + "--public", + "-d", "Created by fgj functional test", + ) + + if result.ExitCode != 0 { + t.Fatalf("repo create failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + if !strings.Contains(result.Stdout, repoName) { + t.Fatalf("expected output to contain repo name %q, got: %s", repoName, result.Stdout) + } + + repo, _, err := env.Client.GetRepo(env.Owner, repoName) + if err != nil { + t.Fatalf("repo was not created or not accessible: %v", err) + } + if repo.Private { + t.Fatalf("expected public repo, got private") + } + if repo.Description != "Created by fgj functional test" { + t.Fatalf("expected description %q, got %q", "Created by fgj functional test", repo.Description) + } + + t.Logf("Successfully created repository %s via CLI", repo.FullName) +} + +func TestCLIRepoEdit(t *testing.T) { + env := NewTestEnv(t) + + repo, _, err := env.Client.GetRepo(env.Owner, env.RepoName) + if err != nil { + t.Fatalf("failed to get repo: %v", err) + } + originalDesc := repo.Description + + defer func() { + env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "repo", "edit", + "-d", originalDesc, + ) + }() + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "repo", "edit", + "-d", "Updated description via functional test", + ) + + if result.ExitCode != 0 { + t.Fatalf("repo edit failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + updatedRepo, _, err := env.Client.GetRepo(env.Owner, env.RepoName) + if err != nil { + t.Fatalf("failed to get repo after edit: %v", err) + } + + if updatedRepo.Description != "Updated description via functional test" { + t.Fatalf("expected description %q, got %q", + "Updated description via functional test", updatedRepo.Description) + } + + t.Logf("Successfully edited repo description via CLI") +} + func TestCLIRepoClone(t *testing.T) { env := NewTestEnv(t) - // For repo clone, we test by cloning the current repository (fgj itself) - // since that doesn't require special permissions tmpDir := t.TempDir() clonePath := fmt.Sprintf("%s/fgj-clone", tmpDir) - // Run: fgj repo clone romaintb/fgj - // Using the public fgj repository to avoid auth issues result := env.RunCLI( "--hostname", env.Hostname, "repo", "clone", - "romaintb/fgj", + fmt.Sprintf("%s/%s", env.Owner, env.RepoName), clonePath, ) if result.ExitCode != 0 { - t.Logf("Note: repo clone failed, which may be due to test environment limitations") - t.Logf("Exit code: %d", result.ExitCode) - t.Logf("Stderr: %s", result.Stderr) - // Skip instead of failing since this might be an auth/config issue in test env t.Skip("repo clone requires full authentication setup") } - // Verify the repository was cloned gitDir := fmt.Sprintf("%s/.git", clonePath) if _, err := os.Stat(gitDir); err != nil { t.Logf("Warning: .git directory not found, clone may not have completed") - t.Logf("Stdout: %s", result.Stdout) - // Don't fail - just note that clone didn't complete - // This might be expected in test environment return } t.Logf("Successfully cloned repository to %s via CLI", clonePath) } -// TestCLIReleaseCreateUploadDelete verifies release create/upload/delete via CLI. +// ===== CLI Release Commands ===== + +func TestCLIReleaseList(t *testing.T) { + env := NewTestEnv(t) + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "release", "list", + ) + + if result.ExitCode != 0 { + t.Fatalf("release list failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + t.Logf("Successfully listed releases via CLI") +} + func TestCLIReleaseCreateUploadDelete(t *testing.T) { env := NewTestEnv(t) @@ -875,6 +811,7 @@ func TestCLIReleaseCreateUploadDelete(t *testing.T) { result := env.RunCLI( "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), "release", "create", tag, "-t", title, "-n", notes, @@ -909,6 +846,7 @@ func TestCLIReleaseCreateUploadDelete(t *testing.T) { deleteResult := env.RunCLI( "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), "release", "delete", tag, ) if deleteResult.ExitCode != 0 { @@ -921,61 +859,369 @@ func TestCLIReleaseCreateUploadDelete(t *testing.T) { } } -// TestCLIReleaseList verifies the `fgj release list` command works. -func TestCLIReleaseList(t *testing.T) { +func TestCLIReleaseView(t *testing.T) { + env := NewTestEnv(t) + + // Create a release to view + tag := fmt.Sprintf("fgj-view-test-%d", time.Now().UnixNano()) + + createResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "release", "create", tag, + "-t", "View Test Release", + "-n", "For release view test", + ) + if createResult.ExitCode != 0 { + t.Fatalf("release create failed: %s", createResult.Stderr) + } + + defer env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "release", "delete", tag, + ) + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "release", "view", tag, + ) + + if result.ExitCode != 0 { + t.Fatalf("release view failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + if !strings.Contains(result.Stdout, "View Test Release") { + t.Fatalf("expected release title in output, got: %s", result.Stdout) + } + + t.Logf("Successfully viewed release %s via CLI", tag) +} + +// ===== CLI Label Commands ===== + +func TestCLILabelCreateListEditDelete(t *testing.T) { + env := NewTestEnv(t) + + rf := fmt.Sprintf("%s/%s", env.Owner, env.RepoName) + + createResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "label", "create", "test-label", + "--color", "00ff00", + "-d", "Test label for functional tests", + ) + + if createResult.ExitCode != 0 { + t.Fatalf("label create failed with exit code %d: %s", createResult.ExitCode, createResult.Stderr) + } + + listResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "label", "list", + ) + + if listResult.ExitCode != 0 { + t.Fatalf("label list failed with exit code %d: %s", listResult.ExitCode, listResult.Stderr) + } + + if !strings.Contains(listResult.Stdout, "test-label") { + t.Fatalf("label list output does not contain 'test-label': %s", listResult.Stdout) + } + + editResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "label", "edit", "test-label", + "--name", "test-label-renamed", + ) + + if editResult.ExitCode != 0 { + t.Fatalf("label edit failed with exit code %d: %s", editResult.ExitCode, editResult.Stderr) + } + + deleteResult := env.runCLIWithStdin("y\n", + "--hostname", env.Hostname, + "-R", rf, + "label", "delete", "test-label-renamed", + ) + + if deleteResult.ExitCode != 0 { + t.Fatalf("label delete failed with exit code %d: %s", deleteResult.ExitCode, deleteResult.Stderr) + } + + t.Logf("Successfully created, listed, edited, and deleted label via CLI") +} + +// ===== CLI Milestone Commands ===== + +func TestCLIMilestoneCreateListViewEditDelete(t *testing.T) { + env := NewTestEnv(t) + + rf := fmt.Sprintf("%s/%s", env.Owner, env.RepoName) + + createResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "milestone", "create", "Test Milestone", + "-d", "For testing", + ) + + if createResult.ExitCode != 0 { + t.Fatalf("milestone create failed with exit code %d: %s", createResult.ExitCode, createResult.Stderr) + } + + listResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "milestone", "list", + ) + + if listResult.ExitCode != 0 { + t.Fatalf("milestone list failed with exit code %d: %s", listResult.ExitCode, listResult.Stderr) + } + + if !strings.Contains(listResult.Stdout, "Test Milestone") { + t.Fatalf("milestone list output does not contain 'Test Milestone': %s", listResult.Stdout) + } + + viewResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "milestone", "view", "Test Milestone", + ) + + if viewResult.ExitCode != 0 { + t.Fatalf("milestone view failed with exit code %d: %s", viewResult.ExitCode, viewResult.Stderr) + } + + editResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "milestone", "edit", "Test Milestone", + "--title", "Updated Milestone", + ) + + if editResult.ExitCode != 0 { + t.Fatalf("milestone edit failed with exit code %d: %s", editResult.ExitCode, editResult.Stderr) + } + + deleteResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "milestone", "delete", "Updated Milestone", + ) + + if deleteResult.ExitCode != 0 { + t.Fatalf("milestone delete failed with exit code %d: %s", deleteResult.ExitCode, deleteResult.Stderr) + } + + t.Logf("Successfully created, listed, viewed, edited, and deleted milestone via CLI") +} + +// ===== CLI Wiki Commands ===== + +func TestCLIWikiCreateListViewEditDelete(t *testing.T) { + env := NewTestEnv(t) + + rf := fmt.Sprintf("%s/%s", env.Owner, env.RepoName) + + createResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "wiki", "create", "Test Page", + "-b", "# Test\nContent", + ) + + if createResult.ExitCode != 0 { + t.Fatalf("wiki create failed with exit code %d: %s", createResult.ExitCode, createResult.Stderr) + } + + defer func() { + env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "wiki", "delete", "Test Page", + ) + }() + + listResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "wiki", "list", + ) + + if listResult.ExitCode != 0 { + t.Fatalf("wiki list failed with exit code %d: %s", listResult.ExitCode, listResult.Stderr) + } + + if !strings.Contains(listResult.Stdout, "Test Page") { + t.Fatalf("wiki list output does not contain 'Test Page': %s", listResult.Stdout) + } + + viewResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "wiki", "view", "Test Page", + ) + + if viewResult.ExitCode != 0 { + t.Fatalf("wiki view failed with exit code %d: %s", viewResult.ExitCode, viewResult.Stderr) + } + + if !strings.Contains(viewResult.Stdout, "Content") { + t.Fatalf("wiki view output does not contain 'Content': %s", viewResult.Stdout) + } + + editResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "wiki", "edit", "Test Page", + "-b", "Updated content", + ) + + if editResult.ExitCode != 0 { + t.Fatalf("wiki edit failed with exit code %d: %s", editResult.ExitCode, editResult.Stderr) + } + + deleteResult := env.RunCLI( + "--hostname", env.Hostname, + "-R", rf, + "wiki", "delete", "Test Page", + ) + + if deleteResult.ExitCode != 0 { + t.Fatalf("wiki delete failed with exit code %d: %s", deleteResult.ExitCode, deleteResult.Stderr) + } + + t.Logf("Successfully created, listed, viewed, edited, and deleted wiki page via CLI") +} + +// ===== CLI Actions Commands ===== + +func TestCLIActionsRunList(t *testing.T) { env := NewTestEnv(t) result := env.RunCLI( "--hostname", env.Hostname, - "release", "list", + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "actions", "run", "list", ) + // Actions might not be enabled, so we accept both success and failure if result.ExitCode != 0 { - t.Fatalf("release list failed with exit code %d: %s", result.ExitCode, result.Stderr) + if bytes.Contains([]byte(result.Stderr), []byte("404")) { + t.Logf("Note: Actions not available on this instance") + return + } + t.Fatalf("actions run list failed with exit code %d: %s", result.ExitCode, result.Stderr) } - if result.Stdout == "" && result.Stderr == "" { - t.Logf("Note: release list produced no output") - } - - t.Logf("Successfully listed releases via CLI") + t.Logf("Successfully listed workflow runs via CLI") } -// TestCLIRepoCreate verifies `fgj repo create` creates a repository -func TestCLIRepoCreate(t *testing.T) { +// ===== CLI API Command ===== + +func TestCLIAPIGet(t *testing.T) { env := NewTestEnv(t) - repoName := fmt.Sprintf("fgj-test-create-%d", time.Now().UnixNano()) - defer env.CleanupRepo(env.Owner, repoName) + endpoint := fmt.Sprintf("/repos/%s/%s", env.Owner, env.RepoName) result := env.RunCLI( "--hostname", env.Hostname, - "repo", "create", repoName, - "--public", - "-d", "Created by fgj functional test", + "api", endpoint, ) if result.ExitCode != 0 { - t.Fatalf("repo create failed with exit code %d: %s", result.ExitCode, result.Stderr) + t.Fatalf("api GET failed with exit code %d: %s", result.ExitCode, result.Stderr) } - if !bytes.Contains([]byte(result.Stdout), []byte(repoName)) { - t.Fatalf("expected output to contain repo name %q, got: %s", repoName, result.Stdout) + var data map[string]interface{} + if err := json.Unmarshal([]byte(result.Stdout), &data); err != nil { + t.Fatalf("failed to parse JSON output: %v", err) } - repo, _, err := env.Client.GetRepo(env.Owner, repoName) - if err != nil { - t.Fatalf("repo was not created or not accessible: %v", err) - } - if repo.Name != repoName { - t.Fatalf("expected repo name %q, got %q", repoName, repo.Name) - } - if repo.Private { - t.Fatalf("expected public repo, got private") - } - if repo.Description != "Created by fgj functional test" { - t.Fatalf("expected description %q, got %q", "Created by fgj functional test", repo.Description) + name, ok := data["name"].(string) + if !ok || name != env.RepoName { + t.Fatalf("expected repo name %q in JSON output, got %v", env.RepoName, data["name"]) } - t.Logf("Successfully created repository %s via CLI", repo.FullName) + t.Logf("Successfully retrieved repo info via fgj api GET") +} + +func TestCLIAPIPostAndDelete(t *testing.T) { + env := NewTestEnv(t) + + endpoint := fmt.Sprintf("/repos/%s/%s/issues", env.Owner, env.RepoName) + + result := env.RunCLI( + "--hostname", env.Hostname, + "api", endpoint, + "-X", "POST", + "-f", "title=[FGJ E2E Test] API Post Test", + "-f", "body=Created via fgj api command", + ) + + if result.ExitCode != 0 { + t.Fatalf("api POST failed with exit code %d: %s", result.ExitCode, result.Stderr) + } + + var issueData map[string]interface{} + if err := json.Unmarshal([]byte(result.Stdout), &issueData); err != nil { + t.Fatalf("failed to parse JSON response: %v", err) + } + + issueNumber, ok := issueData["number"].(float64) + if !ok || issueNumber == 0 { + t.Fatalf("expected issue number in response, got %v", issueData["number"]) + } + + issueNum := int64(issueNumber) + defer env.CleanupIssue(issueNum) + + t.Logf("Successfully created issue #%d via fgj api POST", issueNum) +} + +// ===== Structured Error Output ===== + +func TestCLIJSONErrors(t *testing.T) { + env := NewTestEnv(t) + + result := env.RunCLI( + "--hostname", env.Hostname, + "-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName), + "issue", "view", "999999", + "--json-errors", + ) + + if result.ExitCode == 0 { + t.Fatalf("expected non-zero exit code for non-existent issue, got 0") + } + + // Stderr may contain cobra usage text mixed with JSON error output. + stderr := result.Stderr + jsonStart := strings.Index(stderr, "{") + jsonEnd := strings.LastIndex(stderr, "}") + if jsonStart < 0 || jsonEnd < 0 || jsonEnd <= jsonStart { + t.Fatalf("expected JSON error output in stderr, but no JSON found.\nStderr: %s", stderr) + } + jsonStr := stderr[jsonStart : jsonEnd+1] + + var errData map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &errData); err != nil { + t.Fatalf("expected JSON error output in stderr, got parse error: %v\nJSON: %s\nFull stderr: %s", err, jsonStr, stderr) + } + + if _, hasCode := errData["code"]; !hasCode { + if _, hasMsg := errData["message"]; !hasMsg { + if _, hasErr := errData["error"]; !hasErr { + t.Fatalf("JSON error output missing expected fields (code/message/error): %v", errData) + } + } + } + + t.Logf("Successfully verified --json-errors flag produces structured JSON error output") } From 113505de954327847b6e3f2f8993d580ac676c88 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 23 Mar 2026 11:42:44 -0600 Subject: [PATCH 02/20] =?UTF-8?q?feat:=20v0.3.0d=20=E2=80=94=20add=20PR=20?= =?UTF-8?q?checks,=20iostreams,=20aliases,=20and=20broad=20enhancements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PR checks command, iostreams/text packages for colored table output, top-level run/workflow aliases matching gh CLI structure. Enhance actions, issues, PRs, releases, repos, labels, milestones, and wiki commands with improved flags, JSON output, and error handling. --- .gitignore | 2 + README.md | 7 +- cmd/actions.go | 393 ++++++++------ cmd/aliases.go | 142 ++++++ cmd/api.go | 18 +- cmd/auth.go | 25 +- cmd/errors.go | 44 +- cmd/ios_init.go | 5 + cmd/issue.go | 370 ++++++++++++-- cmd/json.go | 147 +++++- cmd/label.go | 76 +-- cmd/milestone.go | 122 +++-- cmd/pr.go | 872 ++++++++++++++++++++++++++++++-- cmd/pr_checks.go | 99 ++++ cmd/pr_diff.go | 71 ++- cmd/pr_review.go | 31 +- cmd/release.go | 291 +++++++++-- cmd/repo.go | 210 ++++++-- cmd/root.go | 26 +- cmd/wiki.go | 117 +++-- go.mod | 6 +- go.sum | 6 + internal/api/client.go | 67 ++- internal/git/git.go | 42 ++ internal/iostreams/color.go | 77 +++ internal/iostreams/iostreams.go | 272 ++++++++++ internal/iostreams/table.go | 64 +++ internal/text/text.go | 70 +++ main.go | 1 + 29 files changed, 3131 insertions(+), 542 deletions(-) create mode 100644 cmd/aliases.go create mode 100644 cmd/ios_init.go create mode 100644 cmd/pr_checks.go create mode 100644 internal/iostreams/color.go create mode 100644 internal/iostreams/iostreams.go create mode 100644 internal/iostreams/table.go create mode 100644 internal/text/text.go diff --git a/.gitignore b/.gitignore index ec3f87e..07f46ec 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ config.yaml # Git worktrees .worktrees/ +# Workspace (scratch data, cloned repos, analysis) +.workspace/ diff --git a/README.md b/README.md index f8877a2..9da291f 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,11 @@ fgj repo edit owner/repo --public fgj repo edit owner/repo --private fgj repo edit owner/repo -d "New description" --homepage https://example.com fgj repo edit --default-branch develop +fgj repo edit owner/repo --name new-name + +# Rename a repository (shorthand) +fgj repo rename new-name +fgj repo rename new-name -R owner/old-name ``` ### Releases @@ -532,7 +537,7 @@ Contributions are welcome! Please feel free to submit a Pull Request at [forgejo - `pr checks`, `pr ready/draft` - `issue reopen`, `issue assign` - `release edit`, `release download`, `release generate-notes` -- `repo delete`, `repo rename` +- `repo delete` We welcome contributions to implement any of these features! diff --git a/cmd/actions.go b/cmd/actions.go index 5481c5c..fbb0d75 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -3,10 +3,8 @@ package cmd import ( "fmt" "net/http" - "os" "strconv" "strings" - "text/tabwriter" "time" "code.gitea.io/sdk/gitea" @@ -100,39 +98,67 @@ var runListCmd = &cobra.Command{ Use: "list", Short: "List recent workflow runs", Long: "List recent workflow runs for a repository.", - RunE: runRunList, + Example: ` # List recent workflow runs + fgj actions run list + + # List runs with a custom limit + fgj actions run list -L 50 + + # Output as JSON + fgj actions run list --json`, + RunE: runRunList, } var runViewCmd = &cobra.Command{ Use: "view ", Short: "View a workflow run", Long: "View details about a specific workflow run.", - Args: cobra.ExactArgs(1), - RunE: runRunView, + Example: ` # View a workflow run + fgj actions run view 123 + + # View with job details + fgj actions run view 123 -v + + # View logs for a specific job + fgj actions run view 123 --job 456 --log + + # View only failed logs + fgj actions run view 123 --log-failed`, + Args: cobra.ExactArgs(1), + RunE: runRunView, } var runWatchCmd = &cobra.Command{ Use: "watch ", Short: "Watch a workflow run", Long: "Poll a workflow run until it completes.", - Args: cobra.ExactArgs(1), - RunE: runRunWatch, + Example: ` # Watch a run until it completes + fgj actions run watch 123 + + # Watch with a custom polling interval + fgj actions run watch 123 -i 10s`, + Args: cobra.ExactArgs(1), + RunE: runRunWatch, } var runRerunCmd = &cobra.Command{ Use: "rerun ", Short: "Rerun a workflow run", Long: "Trigger a rerun for a specific workflow run.", - Args: cobra.ExactArgs(1), - RunE: runRunRerun, + Example: ` # Rerun a failed workflow run + fgj actions run rerun 123`, + Args: cobra.ExactArgs(1), + RunE: runRunRerun, } var runCancelCmd = &cobra.Command{ Use: "cancel ", Short: "Cancel a workflow run", Long: "Cancel a running workflow run.", - Args: cobra.ExactArgs(1), - RunE: runRunCancel, + Example: ` # Cancel a running workflow + fgj actions run cancel 123`, + Args: cobra.ExactArgs(1), + RunE: runRunCancel, } // Workflow commands @@ -146,39 +172,61 @@ var workflowListCmd = &cobra.Command{ Use: "list", Short: "List workflows", Long: "List all workflows in a repository.", - RunE: runWorkflowList, + Example: ` # List all workflows + fgj actions workflow list + + # List workflows as JSON + fgj actions workflow list --json + + # List workflows for a specific repo + fgj actions workflow list -R owner/repo`, + RunE: runWorkflowList, } var workflowViewCmd = &cobra.Command{ Use: "view ", Short: "View a workflow", Long: "View details about a specific workflow. You can specify the workflow by name or filename.", - Args: cobra.ExactArgs(1), - RunE: runWorkflowView, + Example: ` # View a workflow by filename + fgj actions workflow view ci.yml + + # View as JSON + fgj actions workflow view ci.yml --json`, + Args: cobra.ExactArgs(1), + RunE: runWorkflowView, } var workflowRunCmd = &cobra.Command{ Use: "run ", Short: "Run a workflow", Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.", - Args: cobra.ExactArgs(1), - RunE: runWorkflowRun, + Example: ` # Trigger a workflow on the default branch + fgj actions workflow run deploy.yml + + # Trigger on a specific branch with input parameters + fgj actions workflow run deploy.yml -r staging -f environment=staging -f version=1.2.3`, + Args: cobra.ExactArgs(1), + RunE: runWorkflowRun, } var workflowEnableCmd = &cobra.Command{ Use: "enable ", 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, + Example: ` # Enable a workflow + fgj actions workflow enable ci.yml`, + Args: cobra.ExactArgs(1), + RunE: runWorkflowEnable, } var workflowDisableCmd = &cobra.Command{ Use: "disable ", 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, + Example: ` # Disable a workflow + fgj actions workflow disable ci.yml`, + Args: cobra.ExactArgs(1), + RunE: runWorkflowDisable, } // Secret commands @@ -192,23 +240,35 @@ var actionsSecretListCmd = &cobra.Command{ Use: "list", Short: "List repository secrets", Long: "List all secrets for a repository.", - RunE: runActionsSecretList, + Example: ` # List all secrets + fgj actions secret list + + # List secrets for a specific repo + fgj actions secret list -R owner/repo`, + RunE: runActionsSecretList, } var actionsSecretCreateCmd = &cobra.Command{ Use: "create ", Short: "Create or update a repository secret", Long: "Create or update a secret for Forgejo Actions. The secret value will be read from stdin.", - Args: cobra.ExactArgs(1), - RunE: runActionsSecretCreate, + Example: ` # Create a secret (will prompt for value) + fgj actions secret create DEPLOY_TOKEN + + # Create a secret for a specific repo + fgj actions secret create API_KEY -R owner/repo`, + Args: cobra.ExactArgs(1), + RunE: runActionsSecretCreate, } var actionsSecretDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a repository secret", Long: "Delete a secret from Forgejo Actions.", - Args: cobra.ExactArgs(1), - RunE: runActionsSecretDelete, + Example: ` # Delete a secret + fgj actions secret delete DEPLOY_TOKEN`, + Args: cobra.ExactArgs(1), + RunE: runActionsSecretDelete, } // Variable commands @@ -222,39 +282,55 @@ var actionsVariableListCmd = &cobra.Command{ Use: "list", Short: "List repository variables", Long: "List all variables for a repository.", - RunE: runActionsVariableList, + Example: ` # List all variables + fgj actions variable list + + # List variables for a specific repo + fgj actions variable list -R owner/repo`, + RunE: runActionsVariableList, } var actionsVariableGetCmd = &cobra.Command{ Use: "get ", Short: "Get a repository variable", Long: "Get the value of a specific repository variable.", - Args: cobra.ExactArgs(1), - RunE: runActionsVariableGet, + Example: ` # Get a variable value + fgj actions variable get ENVIRONMENT`, + Args: cobra.ExactArgs(1), + RunE: runActionsVariableGet, } var actionsVariableCreateCmd = &cobra.Command{ Use: "create ", Short: "Create a repository variable", Long: "Create a new variable for Forgejo Actions.", - Args: cobra.ExactArgs(2), - RunE: runActionsVariableCreate, + Example: ` # Create a variable + fgj actions variable create ENVIRONMENT production + + # Create a variable for a specific repo + fgj actions variable create NODE_VERSION 20 -R owner/repo`, + Args: cobra.ExactArgs(2), + RunE: runActionsVariableCreate, } var actionsVariableUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update a repository variable", Long: "Update an existing variable for Forgejo Actions.", - Args: cobra.ExactArgs(2), - RunE: runActionsVariableUpdate, + Example: ` # Update a variable + fgj actions variable update ENVIRONMENT staging`, + Args: cobra.ExactArgs(2), + RunE: runActionsVariableUpdate, } var actionsVariableDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a repository variable", Long: "Delete a variable from Forgejo Actions.", - Args: cobra.ExactArgs(1), - RunE: runActionsVariableDelete, + Example: ` # Delete a variable + fgj actions variable delete ENVIRONMENT`, + Args: cobra.ExactArgs(1), + RunE: runActionsVariableDelete, } func init() { @@ -293,13 +369,13 @@ func init() { // Add flags for run commands addRepoFlags(runListCmd) runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") - runListCmd.Flags().Bool("json", false, "Output workflow runs as JSON") + addJSONFlags(runListCmd, "Output workflow runs as JSON") addRepoFlags(runViewCmd) 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().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().Bool("json", false, "Output workflow run as JSON") + addJSONFlags(runViewCmd, "Output workflow run as JSON") addRepoFlags(runWatchCmd) runWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval") addRepoFlags(runRerunCmd) @@ -308,9 +384,9 @@ func init() { // Add flags for workflow commands addRepoFlags(workflowListCmd) workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list") - workflowListCmd.Flags().Bool("json", false, "Output workflows as JSON") + addJSONFlags(workflowListCmd, "Output workflows as JSON") addRepoFlags(workflowViewCmd) - workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON") + addJSONFlags(workflowViewCmd, "Output workflow as JSON") addRepoFlags(workflowRunCmd) addRepoFlags(workflowEnableCmd) addRepoFlags(workflowDisableCmd) @@ -325,6 +401,7 @@ func init() { // Add flags for variable commands addRepoFlags(actionsVariableListCmd) + addJSONFlags(actionsVariableListCmd, "Output variables as JSON") addRepoFlags(actionsVariableGetCmd) addRepoFlags(actionsVariableCreateCmd) addRepoFlags(actionsVariableUpdateCmd) @@ -364,39 +441,26 @@ func runRunList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list runs: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(runList.WorkflowRuns) + if wantJSON(cmd) { + return outputJSON(cmd, runList.WorkflowRuns) } if len(runList.WorkflowRuns) == 0 { - fmt.Println("No workflow runs found") + fmt.Fprintln(ios.Out, "No workflow runs found") return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - if _, err := fmt.Fprintln(w, "STATUS\tTITLE\tWORKFLOW\tEVENT\tID\tCREATED"); err != nil { - return fmt.Errorf("failed to write header: %w", err) - } - + tp := ios.NewTablePrinter() + tp.AddHeader("STATUS", "TITLE", "WORKFLOW", "EVENT", "ID", "CREATED") for _, run := range runList.WorkflowRuns { createdTime, err := time.Parse(time.RFC3339, run.Created) if err != nil { createdTime = time.Now() } - timeStr := formatTimeSince(createdTime) - - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", - formatStatus(run.Status), run.Title, run.WorkflowID, run.Event, run.ID, timeStr); err != nil { - return fmt.Errorf("failed to write run: %w", err) - } + tp.AddRow(formatStatus(run.Status), run.Title, run.WorkflowID, run.Event, fmt.Sprintf("%d", run.ID), timeStr) } - - if err := w.Flush(); err != nil { - return fmt.Errorf("failed to flush output: %w", err) - } - - return nil + return tp.Render() } func runRunView(cmd *cobra.Command, args []string) error { @@ -425,7 +489,7 @@ func runRunView(cmd *cobra.Command, args []string) error { showLog, _ := cmd.Flags().GetBool("log") jobIDStr, _ := cmd.Flags().GetString("job") showLogFailed, _ := cmd.Flags().GetBool("log-failed") - jsonOutput, _ := cmd.Flags().GetBool("json") + jsonRequested := wantJSON(cmd) var jobID int64 if jobIDStr != "" { @@ -436,7 +500,7 @@ func runRunView(cmd *cobra.Command, args []string) error { } } - if jsonOutput && (showLog || showLogFailed) { + if jsonRequested && (showLog || showLogFailed) { return fmt.Errorf("--json cannot be used with --log or --log-failed") } @@ -450,7 +514,7 @@ func runRunView(cmd *cobra.Command, args []string) error { needsJobs := verbose || showLog || showLogFailed || jobID > 0 - if jsonOutput { + if jsonRequested { var runTasks []ActionTask if needsJobs { tasksEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", owner, name) @@ -487,33 +551,33 @@ func runRunView(cmd *cobra.Command, args []string) error { Run: run, Tasks: runTasks, } - return writeJSON(payload) + return outputJSON(cmd, payload) } // Display run information - fmt.Printf("Title: %s\n", run.Title) - fmt.Printf("Workflow: %s\n", run.WorkflowID) - fmt.Printf("Run: #%d\n", run.IndexInRepo) - fmt.Printf("Status: %s\n", formatStatus(run.Status)) - fmt.Printf("Event: %s\n", run.Event) - fmt.Printf("Ref: %s\n", run.PrettyRef) + fmt.Fprintf(ios.Out, "Title: %s\n", run.Title) + fmt.Fprintf(ios.Out, "Workflow: %s\n", run.WorkflowID) + fmt.Fprintf(ios.Out, "Run: #%d\n", run.IndexInRepo) + fmt.Fprintf(ios.Out, "Status: %s\n", formatStatus(run.Status)) + fmt.Fprintf(ios.Out, "Event: %s\n", run.Event) + fmt.Fprintf(ios.Out, "Ref: %s\n", run.PrettyRef) commit := run.CommitSHA if len(commit) > 8 { commit = commit[:8] } - fmt.Printf("Commit: %s\n", commit) + fmt.Fprintf(ios.Out, "Commit: %s\n", commit) if createdTime, err := time.Parse(time.RFC3339, run.Created); err == nil { - fmt.Printf("Created: %s\n", createdTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Created: %s\n", createdTime.Format("2006-01-02 15:04:05")) } if run.Started != "" { if startedTime, err := time.Parse(time.RFC3339, run.Started); err == nil { - fmt.Printf("Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) } } if updatedTime, err := time.Parse(time.RFC3339, run.Updated); err == nil { - fmt.Printf("Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) } // Fetch jobs if needed for verbose, log, or job-specific views @@ -536,7 +600,7 @@ func runRunView(cmd *cobra.Command, args []string) error { } if len(runTasks) == 0 { - fmt.Println("\nNo jobs found for this run") + fmt.Fprintln(ios.Out, "\nNo jobs found for this run") return nil } @@ -557,14 +621,14 @@ func runRunView(cmd *cobra.Command, args []string) error { // Case 1: --verbose (show job steps/details without logs) if verbose && !showLog && !showLogFailed { - fmt.Println("\nJobs:") + fmt.Fprintln(ios.Out, "\nJobs:") for _, task := range runTasks { - fmt.Printf("\n %s - %s (ID: %d)\n", formatStatus(task.Status), task.Name, task.ID) + fmt.Fprintf(ios.Out, "\n %s - %s (ID: %d)\n", formatStatus(task.Status), task.Name, task.ID) if startedTime, err := time.Parse(time.RFC3339, task.RunStartedAt); err == nil { - fmt.Printf(" Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, " Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) } if updatedTime, err := time.Parse(time.RFC3339, task.UpdatedAt); err == nil { - fmt.Printf(" Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, " Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) } } return nil @@ -574,7 +638,7 @@ func runRunView(cmd *cobra.Command, args []string) error { if showLog || showLogFailed { for _, task := range runTasks { if err := showJobLog(client, owner, name, task, showLogFailed); err != nil { - fmt.Printf("\nError fetching log for job %s: %v\n", task.Name, err) + fmt.Fprintf(ios.Out, "\nError fetching log for job %s: %v\n", task.Name, err) } } return nil @@ -583,15 +647,15 @@ func runRunView(cmd *cobra.Command, args []string) error { // Case 3: --job without --log or --verbose (show job details only) if jobID > 0 { task := runTasks[0] - fmt.Println("\nJob Details:") - fmt.Printf(" Name: %s\n", task.Name) - fmt.Printf(" ID: %d\n", task.ID) - fmt.Printf(" Status: %s\n", formatStatus(task.Status)) + fmt.Fprintln(ios.Out, "\nJob Details:") + fmt.Fprintf(ios.Out, " Name: %s\n", task.Name) + fmt.Fprintf(ios.Out, " ID: %d\n", task.ID) + fmt.Fprintf(ios.Out, " Status: %s\n", formatStatus(task.Status)) if startedTime, err := time.Parse(time.RFC3339, task.RunStartedAt); err == nil { - fmt.Printf(" Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, " Started: %s\n", startedTime.Format("2006-01-02 15:04:05")) } if updatedTime, err := time.Parse(time.RFC3339, task.UpdatedAt); err == nil { - fmt.Printf(" Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, " Updated: %s\n", updatedTime.Format("2006-01-02 15:04:05")) } } @@ -635,12 +699,12 @@ func runRunWatch(cmd *cobra.Command, args []string) error { } if run.Status != lastStatus { - fmt.Printf("Status: %s\n", formatStatus(run.Status)) + fmt.Fprintf(ios.Out, "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)) + fmt.Fprintf(ios.Out, "Run #%d completed with status %s\n", run.IndexInRepo, formatStatus(run.Status)) return nil } @@ -675,7 +739,7 @@ func runRunRerun(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to rerun workflow: %w", err) } - fmt.Printf("✓ Rerun requested for run %d\n", runID) + fmt.Fprintf(ios.Out, "✓ Rerun requested for run %d\n", runID) return nil } @@ -706,7 +770,7 @@ func runRunCancel(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to cancel workflow run: %w", err) } - fmt.Printf("✓ Cancel requested for run %d\n", runID) + fmt.Fprintf(ios.Out, "✓ Cancel requested for run %d\n", runID) return nil } @@ -715,10 +779,10 @@ func showJobLog(client *api.Client, owner, name string, task ActionTask, logFail 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("Job: %s (ID: %d)\n", task.Name, task.ID) - fmt.Printf("Status: %s\n", formatStatus(task.Status)) - fmt.Printf("========================================\n\n") + fmt.Fprintf(ios.Out, "\n========================================\n") + fmt.Fprintf(ios.Out, "Job: %s (ID: %d)\n", task.Name, task.ID) + fmt.Fprintf(ios.Out, "Status: %s\n", formatStatus(task.Status)) + fmt.Fprintf(ios.Out, "========================================\n\n") // Use GetRawLog helper logContent, err := client.GetRawLog(logURL) @@ -731,11 +795,11 @@ func showJobLog(client *api.Client, owner, name string, task ActionTask, logFail if logFailed { // TODO: Implement filtering for failed steps only // This would require parsing the log format and identifying failed step markers - fmt.Println("Note: --log-failed filtering not yet implemented, showing all logs") + fmt.Fprintln(ios.Out, "Note: --log-failed filtering not yet implemented, showing all logs") } - fmt.Print(logContent) - fmt.Println() + fmt.Fprint(ios.Out, logContent) + fmt.Fprintln(ios.Out) return nil } @@ -848,34 +912,25 @@ func runWorkflowList(cmd *cobra.Command, args []string) error { } if len(allWorkflows) == 0 { - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(allWorkflows) + if wantJSON(cmd) { + return outputJSON(cmd, allWorkflows) } - fmt.Println("No workflows found") + fmt.Fprintln(ios.Out, "No workflows found") return nil } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(allWorkflows) + if wantJSON(cmd) { + return outputJSON(cmd, allWorkflows) } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - if _, err := fmt.Fprintln(w, "NAME\tSTATE\tPATH"); err != nil { - return fmt.Errorf("failed to write header: %w", err) - } + tp := ios.NewTablePrinter() + tp.AddHeader("NAME", "STATE", "PATH") for _, workflow := range allWorkflows { - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", - workflow.Name, workflow.State, workflow.Path); err != nil { - return fmt.Errorf("failed to write workflow: %w", err) - } + tp.AddRow(workflow.Name, workflow.State, workflow.Path) } - if err := w.Flush(); err != nil { - return fmt.Errorf("failed to flush output: %w", err) - } - - return nil + return tp.Render() } func runWorkflowView(cmd *cobra.Command, args []string) error { @@ -901,8 +956,6 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { return err } - jsonOutput, _ := cmd.Flags().GetBool("json") - var latestRun *ActionRun // Get the latest run for this workflow @@ -912,7 +965,7 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { latestRun = &runList.WorkflowRuns[0] } - if jsonOutput { + if wantJSON(cmd) { payload := struct { Workflow *Workflow `json:"workflow"` LatestRun *ActionRun `json:"latest_run,omitempty"` @@ -920,21 +973,21 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { Workflow: workflow, LatestRun: latestRun, } - return writeJSON(payload) + return outputJSON(cmd, payload) } // Display workflow information - fmt.Printf("Name: %s\n", workflow.Name) - fmt.Printf("Path: %s\n", workflow.Path) - fmt.Printf("State: %s\n", workflow.State) + fmt.Fprintf(ios.Out, "Name: %s\n", workflow.Name) + fmt.Fprintf(ios.Out, "Path: %s\n", workflow.Path) + fmt.Fprintf(ios.Out, "State: %s\n", workflow.State) if latestRun != nil { - fmt.Printf("\nLatest run:\n") - fmt.Printf(" Status: %s\n", formatStatus(latestRun.Status)) - fmt.Printf(" Event: %s\n", latestRun.Event) - fmt.Printf(" Ref: %s\n", latestRun.PrettyRef) + fmt.Fprintf(ios.Out, "\nLatest run:\n") + fmt.Fprintf(ios.Out, " Status: %s\n", formatStatus(latestRun.Status)) + fmt.Fprintf(ios.Out, " Event: %s\n", latestRun.Event) + fmt.Fprintf(ios.Out, " Ref: %s\n", latestRun.PrettyRef) if createdTime, err := time.Parse(time.RFC3339, latestRun.Created); err == nil { - fmt.Printf(" Created: %s\n", formatTimeSince(createdTime)) + fmt.Fprintf(ios.Out, " Created: %s\n", formatTimeSince(createdTime)) } } @@ -1006,12 +1059,12 @@ func runWorkflowRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to trigger workflow: %w", err) } - fmt.Printf("✓ Workflow '%s' triggered successfully\n", workflowIdentifier) - fmt.Printf(" Branch/Tag: %s\n", ref) + fmt.Fprintf(ios.Out, "✓ Workflow '%s' triggered successfully\n", workflowIdentifier) + fmt.Fprintf(ios.Out, " Branch/Tag: %s\n", ref) if len(inputs) > 0 { - fmt.Println(" Inputs:") + fmt.Fprintln(ios.Out, " Inputs:") for key, value := range inputs { - fmt.Printf(" %s: %s\n", key, value) + fmt.Fprintf(ios.Out, " %s: %s\n", key, value) } } @@ -1059,7 +1112,7 @@ func runWorkflowEnable(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to enable workflow: %w", err) } - fmt.Printf("✓ Workflow '%s' enabled\n", workflow.Name) + fmt.Fprintf(ios.Out, "✓ Workflow '%s' enabled\n", workflow.Name) return nil } @@ -1104,7 +1157,7 @@ func runWorkflowDisable(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to disable workflow: %w", err) } - fmt.Printf("✓ Workflow '%s' disabled\n", workflow.Name) + fmt.Fprintf(ios.Out, "✓ Workflow '%s' disabled\n", workflow.Name) return nil } @@ -1170,24 +1223,16 @@ func runActionsSecretList(cmd *cobra.Command, args []string) error { } if len(secrets) == 0 { - fmt.Println("No secrets found") + fmt.Fprintln(ios.Out, "No secrets found") return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - if _, err := fmt.Fprintln(w, "NAME\tCREATED"); err != nil { - return fmt.Errorf("failed to write header: %w", err) - } + tp := ios.NewTablePrinter() + tp.AddHeader("NAME", "CREATED") for _, secret := range secrets { - if _, err := fmt.Fprintf(w, "%s\t%s\n", secret.Name, secret.Created.Format("2006-01-02 15:04:05")); err != nil { - return fmt.Errorf("failed to write secret: %w", err) - } + tp.AddRow(secret.Name, secret.Created.Format("2006-01-02 15:04:05")) } - if err := w.Flush(); err != nil { - return fmt.Errorf("failed to flush output: %w", err) - } - - return nil + return tp.Render() } func runActionsSecretCreate(cmd *cobra.Command, args []string) error { @@ -1210,7 +1255,7 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error { secretName := args[0] // Read secret value from stdin - fmt.Print("Enter secret value: ") + fmt.Fprint(ios.ErrOut, "Enter secret value: ") var secretValue string _, err = fmt.Scanln(&secretValue) if err != nil { @@ -1227,7 +1272,7 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create secret: %w", err) } - fmt.Printf("Secret '%s' created successfully\n", secretName) + fmt.Fprintf(ios.Out, "Secret '%s' created successfully\n", secretName) return nil } @@ -1255,15 +1300,57 @@ func runActionsSecretDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to delete secret: %w", err) } - fmt.Printf("Secret '%s' deleted successfully\n", secretName) + fmt.Fprintf(ios.Out, "Secret '%s' deleted successfully\n", secretName) return nil } // Variable command implementations +// ActionVariable represents a repository action variable from the API +type ActionVariable struct { + Name string `json:"name"` + Value string `json:"data"` +} + func runActionsVariableList(cmd *cobra.Command, args []string) error { - // Note: The SDK doesn't have a ListRepoActionVariable method yet - return fmt.Errorf("listing variables is not yet supported in the SDK") + 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 + } + + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables", owner, name) + + var variables []ActionVariable + if err := client.GetJSON(endpoint, &variables); err != nil { + return fmt.Errorf("failed to list variables: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, variables) + } + + if len(variables) == 0 { + fmt.Fprintln(ios.Out, "No variables found") + return nil + } + + tp := ios.NewTablePrinter() + tp.AddHeader("NAME", "VALUE") + for _, v := range variables { + tp.AddRow(v.Name, v.Value) + } + return tp.Render() } func runActionsVariableGet(cmd *cobra.Command, args []string) error { @@ -1290,7 +1377,7 @@ func runActionsVariableGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get variable: %w", err) } - fmt.Printf("%s=%s\n", variable.Name, variable.Value) + fmt.Fprintf(ios.Out, "%s=%s\n", variable.Name, variable.Value) return nil } @@ -1319,7 +1406,7 @@ func runActionsVariableCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create variable: %w", err) } - fmt.Printf("Variable '%s' created successfully\n", variableName) + fmt.Fprintf(ios.Out, "Variable '%s' created successfully\n", variableName) return nil } @@ -1348,7 +1435,7 @@ func runActionsVariableUpdate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to update variable: %w", err) } - fmt.Printf("Variable '%s' updated successfully\n", variableName) + fmt.Fprintf(ios.Out, "Variable '%s' updated successfully\n", variableName) return nil } @@ -1376,6 +1463,6 @@ func runActionsVariableDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to delete variable: %w", err) } - fmt.Printf("Variable '%s' deleted successfully\n", variableName) + fmt.Fprintf(ios.Out, "Variable '%s' deleted successfully\n", variableName) return nil } diff --git a/cmd/aliases.go b/cmd/aliases.go new file mode 100644 index 0000000..10447e7 --- /dev/null +++ b/cmd/aliases.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "time" + + "github.com/spf13/cobra" +) + +// Top-level aliases for "actions run" and "actions workflow" commands, +// matching gh CLI's command structure (e.g., "fgj run list" instead of "fgj actions run list"). + +func init() { + // --- run alias --- + runAliasCmd := &cobra.Command{ + Use: "run", + Short: "View and manage workflow runs (alias for 'actions run')", + Long: "List, view, and manage workflow runs.\n\nThis is a top-level alias for 'actions run'.", + } + + runAliasListCmd := &cobra.Command{ + Use: "list", + Short: "List recent workflow runs", + Long: "List recent workflow runs for a repository.", + RunE: runRunList, + } + addRepoFlags(runAliasListCmd) + runAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") + runAliasListCmd.Flags().Bool("json", false, "Output workflow runs as JSON") + + runAliasViewCmd := &cobra.Command{ + Use: "view ", + Short: "View a workflow run", + Long: "View details about a specific workflow run.", + Args: cobra.ExactArgs(1), + RunE: runRunView, + } + addRepoFlags(runAliasViewCmd) + runAliasViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps") + runAliasViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job") + runAliasViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run") + runAliasViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job") + runAliasViewCmd.Flags().Bool("json", false, "Output workflow run as JSON") + + runAliasWatchCmd := &cobra.Command{ + Use: "watch ", + Short: "Watch a workflow run", + Long: "Poll a workflow run until it completes.", + Args: cobra.ExactArgs(1), + RunE: runRunWatch, + } + addRepoFlags(runAliasWatchCmd) + runAliasWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval") + + runAliasRerunCmd := &cobra.Command{ + Use: "rerun ", + Short: "Rerun a workflow run", + Long: "Trigger a rerun for a specific workflow run.", + Args: cobra.ExactArgs(1), + RunE: runRunRerun, + } + addRepoFlags(runAliasRerunCmd) + + runAliasCancelCmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a workflow run", + Long: "Cancel a running workflow run.", + Args: cobra.ExactArgs(1), + RunE: runRunCancel, + } + addRepoFlags(runAliasCancelCmd) + + runAliasCmd.AddCommand(runAliasListCmd) + runAliasCmd.AddCommand(runAliasViewCmd) + runAliasCmd.AddCommand(runAliasWatchCmd) + runAliasCmd.AddCommand(runAliasRerunCmd) + runAliasCmd.AddCommand(runAliasCancelCmd) + rootCmd.AddCommand(runAliasCmd) + + // --- workflow alias --- + workflowAliasCmd := &cobra.Command{ + Use: "workflow", + Short: "Manage workflows (alias for 'actions workflow')", + Long: "List, view, and run workflows.\n\nThis is a top-level alias for 'actions workflow'.", + } + + workflowAliasListCmd := &cobra.Command{ + Use: "list", + Short: "List workflows", + Long: "List all workflows in a repository.", + RunE: runWorkflowList, + } + addRepoFlags(workflowAliasListCmd) + workflowAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list") + workflowAliasListCmd.Flags().Bool("json", false, "Output workflows as JSON") + + workflowAliasViewCmd := &cobra.Command{ + Use: "view ", + Short: "View a workflow", + Long: "View details about a specific workflow. You can specify the workflow by name or filename.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowView, + } + addRepoFlags(workflowAliasViewCmd) + workflowAliasViewCmd.Flags().Bool("json", false, "Output workflow as JSON") + + workflowAliasRunCmd := &cobra.Command{ + Use: "run ", + Short: "Run a workflow", + Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.", + Args: cobra.ExactArgs(1), + RunE: runWorkflowRun, + } + addRepoFlags(workflowAliasRunCmd) + workflowAliasRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)") + workflowAliasRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)") + workflowAliasRunCmd.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)") + + workflowAliasEnableCmd := &cobra.Command{ + Use: "enable ", + 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, + } + addRepoFlags(workflowAliasEnableCmd) + + workflowAliasDisableCmd := &cobra.Command{ + Use: "disable ", + 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, + } + addRepoFlags(workflowAliasDisableCmd) + + workflowAliasCmd.AddCommand(workflowAliasListCmd) + workflowAliasCmd.AddCommand(workflowAliasViewCmd) + workflowAliasCmd.AddCommand(workflowAliasRunCmd) + workflowAliasCmd.AddCommand(workflowAliasEnableCmd) + workflowAliasCmd.AddCommand(workflowAliasDisableCmd) + rootCmd.AddCommand(workflowAliasCmd) +} diff --git a/cmd/api.go b/cmd/api.go index e6eee18..873f16b 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -171,8 +171,10 @@ func runAPI(cmd *cobra.Command, args []string) error { } // Execute request + ios.StartSpinner("Requesting...") httpClient := &http.Client{} resp, err := httpClient.Do(req) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to perform request: %w", err) } @@ -180,13 +182,13 @@ func runAPI(cmd *cobra.Command, args []string) error { // Print response headers if requested if include { - fmt.Fprintf(os.Stdout, "%s %s\n", resp.Proto, resp.Status) + fmt.Fprintf(ios.Out, "%s %s\n", resp.Proto, resp.Status) for key, values := range resp.Header { for _, v := range values { - fmt.Fprintf(os.Stdout, "%s: %s\n", key, v) + fmt.Fprintf(ios.Out, "%s: %s\n", key, v) } } - fmt.Fprintln(os.Stdout) + fmt.Fprintln(ios.Out) } // Read response body @@ -198,12 +200,12 @@ func runAPI(cmd *cobra.Command, args []string) error { // Handle non-2xx status codes if resp.StatusCode < 200 || resp.StatusCode >= 300 { if !silent { - fmt.Fprint(os.Stderr, string(respBody)) + fmt.Fprint(ios.ErrOut, string(respBody)) if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' { - fmt.Fprintln(os.Stderr) + fmt.Fprintln(ios.ErrOut) } } - os.Exit(1) + return fmt.Errorf("API request failed with status %d", resp.StatusCode) } if silent || len(respBody) == 0 { @@ -215,14 +217,14 @@ func runAPI(cmd *cobra.Command, args []string) error { if strings.Contains(contentType, "json") || json.Valid(respBody) { var parsed any if err := json.Unmarshal(respBody, &parsed); err == nil { - enc := json.NewEncoder(os.Stdout) + enc := json.NewEncoder(ios.Out) enc.SetIndent("", " ") return enc.Encode(parsed) } } // Raw output for non-JSON responses - _, err = os.Stdout.Write(respBody) + _, err = ios.Out.Write(respBody) return err } diff --git a/cmd/auth.go b/cmd/auth.go index d65fbe1..832e14d 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -68,7 +68,7 @@ func runAuthLogin(cmd *cobra.Command, args []string) error { reader := bufio.NewReader(os.Stdin) if hostname == "" { - fmt.Print("Forgejo instance hostname (default: codeberg.org): ") + fmt.Fprint(ios.ErrOut, "Forgejo instance hostname (default: codeberg.org): ") input, _ := reader.ReadString('\n') hostname = strings.TrimSpace(input) if hostname == "" { @@ -77,12 +77,12 @@ func runAuthLogin(cmd *cobra.Command, args []string) error { } if token == "" { - fmt.Print("Personal access token: ") + fmt.Fprint(ios.ErrOut, "Personal access token: ") tokenBytes, err := term.ReadPassword(int(syscall.Stdin)) if err != nil { return fmt.Errorf("failed to read token: %w", err) } - fmt.Println() + fmt.Fprintln(ios.ErrOut) token = strings.TrimSpace(string(tokenBytes)) } @@ -95,7 +95,9 @@ func runAuthLogin(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create client: %w", err) } + ios.StartSpinner("Authenticating...") user, _, err := client.GetMyUserInfo() + ios.StopSpinner() if err != nil { return fmt.Errorf("authentication failed: %w", err) } @@ -116,7 +118,8 @@ func runAuthLogin(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to save config: %w", err) } - fmt.Printf("✓ Authenticated as %s on %s\n", user.UserName, hostname) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Authenticated as %s on %s\n", cs.SuccessIcon(), user.UserName, hostname) return nil } @@ -128,14 +131,15 @@ func runAuthStatus(cmd *cobra.Command, args []string) error { } if len(cfg.Hosts) == 0 { - fmt.Println("Not authenticated with any Forgejo instances") - fmt.Println("Run 'fgj auth login' to authenticate") + fmt.Fprintln(ios.Out, "Not authenticated with any Forgejo instances") + fmt.Fprintln(ios.Out, "Run 'fgj auth login' to authenticate") return nil } - fmt.Println("Authenticated instances:") + fmt.Fprintln(ios.Out, "Authenticated instances:") for hostname, host := range cfg.Hosts { - fmt.Printf(" • %s (user: %s)\n", hostname, host.User) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, " %s %s (user: %s)\n", cs.SuccessIcon(), hostname, host.User) } return nil @@ -158,7 +162,8 @@ func runAuthLogout(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to save config: %w", err) } - fmt.Printf("✓ Logged out from %s\n", resolved) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Logged out from %s\n", cs.SuccessIcon(), resolved) return nil } @@ -174,7 +179,7 @@ func runAuthToken(cmd *cobra.Command, args []string) error { return err } - fmt.Println(cfg.Hosts[resolved].Token) + fmt.Fprintln(ios.Out, cfg.Hosts[resolved].Token) return nil } diff --git a/cmd/errors.go b/cmd/errors.go index 422cd33..45ae602 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -3,7 +3,8 @@ package cmd import ( "encoding/json" "errors" - "os" + "fmt" + "strings" "forgejo.zerova.net/sid/fgj-sid/internal/api" ) @@ -40,6 +41,45 @@ func NewAPIError(status int, message string) *CLIError { return &CLIError{Code: ErrAPIError, Message: message, Status: status} } +// ContextualError wraps common errors with helpful hints. +func ContextualError(err error) error { + if err == nil { + return nil + } + + msg := err.Error() + + // Check for API errors with status codes + var apiErr *api.APIError + if errors.As(err, &apiErr) { + switch { + case apiErr.StatusCode == 401 || apiErr.StatusCode == 403: + return fmt.Errorf("%w\nHint: Try authenticating with: fgj auth login", err) + case apiErr.StatusCode == 404: + return fmt.Errorf("%w\nHint: Resource not found. Check the repository and number are correct.", err) + } + return err + } + + // Check for network/connection errors + switch { + case strings.Contains(msg, "no such host"): + return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err) + case strings.Contains(msg, "connection refused"): + return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err) + } + + // Check for string-based status code patterns (from wrapped errors) + switch { + case strings.Contains(msg, "401") || strings.Contains(msg, "403"): + if strings.Contains(msg, "authentication") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden") { + return fmt.Errorf("%w\nHint: Try authenticating with: fgj auth login", err) + } + } + + return err +} + // writeJSONError writes a structured JSON error to stderr. // It attempts to extract structured info from known error types. // WriteJSONError writes a structured JSON error to stderr. @@ -70,7 +110,7 @@ func WriteJSONError(err error) { } } - enc := json.NewEncoder(os.Stderr) + enc := json.NewEncoder(ios.ErrOut) enc.SetIndent("", " ") _ = enc.Encode(cliErr) } diff --git a/cmd/ios_init.go b/cmd/ios_init.go new file mode 100644 index 0000000..f4846e5 --- /dev/null +++ b/cmd/ios_init.go @@ -0,0 +1,5 @@ +package cmd + +import "forgejo.zerova.net/sid/fgj-sid/internal/iostreams" + +var ios = iostreams.New() diff --git a/cmd/issue.go b/cmd/issue.go index 42ef982..df752d4 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -3,14 +3,12 @@ package cmd import ( "fmt" "net/http" - "os" - "strconv" "strings" - "text/tabwriter" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) @@ -24,46 +22,114 @@ var issueListCmd = &cobra.Command{ Use: "list [flags]", Short: "List issues", Long: "List issues in a repository.", - RunE: runIssueList, + Example: ` # List open issues + fgj issue list + + # List closed issues for a specific repo + fgj issue list -s closed -R owner/repo + + # Output as JSON + fgj issue list --json`, + RunE: runIssueList, } var issueViewCmd = &cobra.Command{ Use: "view ", Short: "View an issue", Long: "Display detailed information about an issue.", - Args: cobra.ExactArgs(1), - RunE: runIssueView, + Example: ` # View issue #42 + fgj issue view 42 + + # View using URL + fgj issue view https://codeberg.org/owner/repo/issues/42 + + # Open in browser + fgj issue view 42 --web + + # View an issue from a specific repo as JSON + fgj issue view 42 -R owner/repo --json`, + Args: cobra.ExactArgs(1), + RunE: runIssueView, } var issueCreateCmd = &cobra.Command{ Use: "create", Short: "Create an issue", Long: "Create a new issue.", - RunE: runIssueCreate, + Example: ` # Create an issue with a title + fgj issue create -t "Fix login bug" + + # Create an issue with title, body, and labels + fgj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`, + RunE: runIssueCreate, } var issueCommentCmd = &cobra.Command{ Use: "comment ", Short: "Add a comment to an issue", Long: "Add a comment to an existing issue.", - Args: cobra.ExactArgs(1), - RunE: runIssueComment, + Example: ` # Add a comment to issue #42 + fgj issue comment 42 -b "This is fixed in the latest release" + + # Comment on an issue in a specific repo + fgj issue comment 10 -b "Confirmed on my end" -R owner/repo`, + Args: cobra.ExactArgs(1), + RunE: runIssueComment, } var issueCloseCmd = &cobra.Command{ Use: "close ", Short: "Close an issue", Long: "Close an existing issue.", - Args: cobra.ExactArgs(1), - RunE: runIssueClose, + Example: ` # Close issue #42 + fgj issue close 42 + + # Close with a comment + fgj issue close 42 -c "Fixed in commit abc1234"`, + Args: cobra.ExactArgs(1), + RunE: runIssueClose, +} + +var issueReopenCmd = &cobra.Command{ + Use: "reopen ", + Short: "Reopen an issue", + Long: "Reopen a closed issue.", + Example: ` # Reopen issue #42 + fgj issue reopen 42`, + Args: cobra.ExactArgs(1), + RunE: runIssueReopen, +} + +var issueDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an issue", + Long: "Delete an issue permanently.", + Example: ` # Delete issue #42 + fgj issue delete 42 + + # Delete without confirmation + fgj issue delete 42 -y`, + Args: cobra.ExactArgs(1), + RunE: runIssueDelete, } var issueEditCmd = &cobra.Command{ Use: "edit ", Short: "Edit an issue", Long: "Edit an existing issue's title, body, or state.", - Args: cobra.ExactArgs(1), - RunE: runIssueEdit, + Example: ` # Update the title of issue #42 + fgj issue edit 42 -t "Updated title" + + # Reopen a closed issue + fgj issue edit 42 -s open + + # Add and remove labels + fgj issue edit 42 --add-label bug --remove-label wontfix + + # Add a dependency + fgj issue edit 42 --add-dependency 10`, + Args: cobra.ExactArgs(1), + RunE: runIssueEdit, } func init() { @@ -73,19 +139,31 @@ func init() { issueCmd.AddCommand(issueCreateCmd) issueCmd.AddCommand(issueCommentCmd) issueCmd.AddCommand(issueCloseCmd) + issueCmd.AddCommand(issueReopenCmd) + issueCmd.AddCommand(issueDeleteCmd) issueCmd.AddCommand(issueEditCmd) + issueReopenCmd.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().Bool("json", false, "Output issues as JSON") + issueListCmd.Flags().IntP("limit", "L", 30, "Maximum number of results") + issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee username") + issueListCmd.Flags().String("author", "", "Filter by author username") + issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names") + issueListCmd.Flags().StringP("search", "S", "", "Search keyword filter") + addJSONFlags(issueListCmd, "Output issues as JSON") issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - issueViewCmd.Flags().Bool("json", false, "Output issue as JSON") + addJSONFlags(issueViewCmd, "Output issue as JSON") + issueViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue") issueCreateCmd.Flags().StringP("body", "b", "", "Body for the issue") issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Labels to add (can be specified multiple times)") + issueCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their login. Use \"@me\" to self-assign.") + issueCreateCmd.Flags().StringP("milestone", "m", "", "Milestone name to associate with the issue") issueCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueCommentCmd.Flags().StringP("body", "b", "", "Comment body") @@ -93,6 +171,9 @@ func init() { issueCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing") + issueDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + issueDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + issueEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue") issueEditCmd.Flags().StringP("body", "b", "", "New body for the issue") @@ -106,6 +187,11 @@ func init() { func runIssueList(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") state, _ := cmd.Flags().GetString("state") + limit, _ := cmd.Flags().GetInt("limit") + assignee, _ := cmd.Flags().GetString("assignee") + author, _ := cmd.Flags().GetString("author") + labels, _ := cmd.Flags().GetStringSlice("label") + search, _ := cmd.Flags().GetString("search") owner, name, err := parseRepo(repo) if err != nil { @@ -134,9 +220,16 @@ func runIssueList(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid state: %s", state) } + ios.StartSpinner("Fetching issues...") issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{ - State: stateType, + State: stateType, + Labels: labels, + KeyWord: search, + CreatedBy: author, + AssignedBy: assignee, + ListOptions: gitea.ListOptions{PageSize: limit}, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to list issues: %w", err) } @@ -148,28 +241,26 @@ func runIssueList(cmd *cobra.Command, args []string) error { } } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(nonPRIssues) + if wantJSON(cmd) { + return outputJSON(cmd, nonPRIssues) } if len(nonPRIssues) == 0 { - fmt.Printf("No %s issues in %s/%s\n", state, owner, name) + fmt.Fprintf(ios.Out, "No %s issues in %s/%s\n", state, owner, name) return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n") + tp := ios.NewTablePrinter() + tp.AddHeader("NUMBER", "TITLE", "STATE") for _, issue := range nonPRIssues { - _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State) + tp.AddRow(fmt.Sprintf("#%d", issue.Index), issue.Title, string(issue.State)) } - _ = w.Flush() - - return nil + return tp.Render() } func runIssueView(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") - issueNumber, err := strconv.ParseInt(args[0], 10, 64) + issueNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue number: %w", err) } @@ -189,8 +280,10 @@ func runIssueView(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching issue...") issue, _, err := client.GetIssue(owner, name, issueNumber) if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to get issue: %w", err) } @@ -199,8 +292,13 @@ func runIssueView(cmd *cobra.Command, args []string) error { if err != nil { comments = nil } + ios.StopSpinner() - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + if web, _ := cmd.Flags().GetBool("web"); web { + return ios.OpenInBrowser(issue.HTMLURL) + } + + if wantJSON(cmd) { payload := struct { Issue *gitea.Issue `json:"issue"` Comments []*gitea.Comment `json:"comments,omitempty"` @@ -208,26 +306,34 @@ func runIssueView(cmd *cobra.Command, args []string) error { Issue: issue, Comments: comments, } - return writeJSON(payload) + return outputJSON(cmd, payload) } - fmt.Printf("Issue #%d\n", issue.Index) - fmt.Printf("Title: %s\n", issue.Title) - fmt.Printf("State: %s\n", issue.State) - fmt.Printf("Author: %s\n", issue.Poster.UserName) - fmt.Printf("Created: %s\n", issue.Created.Format("2006-01-02 15:04:05")) - fmt.Printf("Updated: %s\n", issue.Updated.Format("2006-01-02 15:04:05")) + if err := ios.StartPager(); err != nil { + fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err) + } + defer ios.StopPager() + + cs := ios.ColorScheme() + isTTY := ios.IsStdoutTTY() + + fmt.Fprintf(ios.Out, "Issue #%d\n", issue.Index) + fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(issue.Title)) + fmt.Fprintf(ios.Out, "State: %s\n", issue.State) + fmt.Fprintf(ios.Out, "Author: %s\n", issue.Poster.UserName) + fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(issue.Created, isTTY)) + fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(issue.Updated, isTTY)) if issue.Body != "" { - fmt.Printf("\n%s\n", issue.Body) + fmt.Fprintf(ios.Out, "\n%s\n", issue.Body) } if len(comments) > 0 { - fmt.Printf("\nComments (%d):\n", len(comments)) + fmt.Fprintf(ios.Out, "\nComments (%d):\n", len(comments)) for _, comment := range comments { - fmt.Printf("\n---\n%s (@%s) - %s\n%s\n", + fmt.Fprintf(ios.Out, "\n---\n%s (@%s) - %s\n%s\n", comment.Poster.FullName, comment.Poster.UserName, - comment.Created.Format("2006-01-02 15:04:05"), + text.FormatDate(comment.Created, isTTY), comment.Body) } } @@ -240,14 +346,28 @@ func runIssueCreate(cmd *cobra.Command, args []string) error { title, _ := cmd.Flags().GetString("title") body, _ := cmd.Flags().GetString("body") labelNames, _ := cmd.Flags().GetStringSlice("label") + assignees, _ := cmd.Flags().GetStringSlice("assignee") + milestoneName, _ := cmd.Flags().GetString("milestone") owner, name, err := parseRepo(repo) if err != nil { return err } - if title == "" { - return fmt.Errorf("title is required") + // Interactive mode: prompt for missing fields when TTY + if title == "" && ios.IsStdinTTY() { + title, err = promptLine("Title: ") + if err != nil { + return err + } + if title == "" { + return fmt.Errorf("title is required") + } + if body == "" { + body, _ = promptLine("Body (optional): ") + } + } else if title == "" { + return fmt.Errorf("title is required (use -t flag)") } cfg, err := config.Load() @@ -268,17 +388,56 @@ func runIssueCreate(cmd *cobra.Command, args []string) error { } } + // Resolve @me in assignees + resolvedAssignees := make([]string, 0, len(assignees)) + for _, assignee := range assignees { + if assignee == "@me" { + user, _, userErr := client.GetMyUserInfo() + if userErr != nil { + return fmt.Errorf("failed to get current user info: %w", userErr) + } + resolvedAssignees = append(resolvedAssignees, user.UserName) + } else { + resolvedAssignees = append(resolvedAssignees, assignee) + } + } + + // Resolve milestone name to ID + var milestoneID int64 + if milestoneName != "" { + milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{}) + if msErr != nil { + return fmt.Errorf("failed to list milestones: %w", msErr) + } + found := false + for _, ms := range milestones { + if ms.Title == milestoneName { + milestoneID = ms.ID + found = true + break + } + } + if !found { + return fmt.Errorf("milestone not found: %s", milestoneName) + } + } + + ios.StartSpinner("Creating issue...") issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{ - Title: title, - Body: body, - Labels: labelIDs, + Title: title, + Body: body, + Labels: labelIDs, + Assignees: resolvedAssignees, + Milestone: milestoneID, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create issue: %w", err) } - fmt.Printf("Issue created: #%d\n", issue.Index) - fmt.Printf("View at: %s\n", issue.HTMLURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Issue created: #%d\n", cs.SuccessIcon(), issue.Index) + fmt.Fprintf(ios.Out, "View at: %s\n", issue.HTMLURL) return nil } @@ -286,7 +445,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error { func runIssueComment(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") body, _ := cmd.Flags().GetString("body") - issueNumber, err := strconv.ParseInt(args[0], 10, 64) + issueNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue number: %w", err) } @@ -310,15 +469,18 @@ func runIssueComment(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Adding comment...") comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{ Body: body, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create comment: %w", err) } - fmt.Printf("Comment added to issue #%d\n", issueNumber) - fmt.Printf("View at: %s\n", comment.HTMLURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Comment added to issue #%d\n", cs.SuccessIcon(), issueNumber) + fmt.Fprintf(ios.Out, "View at: %s\n", comment.HTMLURL) return nil } @@ -326,7 +488,7 @@ func runIssueComment(cmd *cobra.Command, args []string) error { func runIssueClose(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") commentBody, _ := cmd.Flags().GetString("comment") - issueNumber, err := strconv.ParseInt(args[0], 10, 64) + issueNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue number: %w", err) } @@ -347,23 +509,28 @@ func runIssueClose(cmd *cobra.Command, args []string) error { } if commentBody != "" { + ios.StartSpinner("Adding comment...") _, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{ Body: commentBody, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create comment: %w", err) } } + ios.StartSpinner("Closing issue...") stateClosed := gitea.StateClosed _, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{ State: &stateClosed, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to close issue: %w", err) } - fmt.Printf("Issue #%d closed\n", issueNumber) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Issue #%d closed\n", cs.SuccessIcon(), issueNumber) return nil } @@ -378,7 +545,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { addDeps, _ := cmd.Flags().GetInt64Slice("add-dependency") removeDeps, _ := cmd.Flags().GetInt64Slice("remove-dependency") - issueNumber, err := strconv.ParseInt(args[0], 10, 64) + issueNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue number: %w", err) } @@ -425,9 +592,12 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { } } + ios.StartSpinner("Updating issue...") + if title != "" || body != "" || stateStr != "" { _, _, err = client.EditIssue(owner, name, issueNumber, editOpt) if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to edit issue: %w", err) } } @@ -435,12 +605,14 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { if len(addLabelNames) > 0 { labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames) if err != nil { + ios.StopSpinner() return err } _, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{ Labels: labelIDs, }) if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to add labels: %w", err) } } @@ -448,16 +620,20 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { if len(removeLabelNames) > 0 { labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames) if err != nil { + ios.StopSpinner() return err } for _, labelID := range labelIDs { _, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID) if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to remove label %d: %w", labelID, err) } } } + ios.StopSpinner() + for _, depNumber := range addDeps { depIssue, _, err := client.GetIssue(owner, name, depNumber) if err != nil { @@ -469,7 +645,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to add dependency #%d: %w", depNumber, err) } - fmt.Printf("Added dependency: #%d depends on #%d\n", issueNumber, depNumber) + fmt.Fprintf(ios.Out, "Added dependency: #%d depends on #%d\n", issueNumber, depNumber) } for _, depNumber := range removeDeps { @@ -483,10 +659,96 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to remove dependency #%d: %w", depNumber, err) } - fmt.Printf("Removed dependency: #%d no longer depends on #%d\n", issueNumber, depNumber) + fmt.Fprintf(ios.Out, "Removed dependency: #%d no longer depends on #%d\n", issueNumber, depNumber) } - fmt.Printf("Issue #%d updated\n", issueNumber) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Issue #%d updated\n", cs.SuccessIcon(), issueNumber) + + return nil +} + +func runIssueDelete(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + yes, _ := cmd.Flags().GetBool("yes") + + issueNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid issue number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + if !yes { + confirmed, confirmErr := ios.ConfirmAction(fmt.Sprintf("Permanently delete issue #%d from %s/%s?", issueNumber, owner, name)) + if confirmErr != nil { + return confirmErr + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } + } + + ios.StartSpinner("Deleting issue...") + _, err = client.DeleteIssue(owner, name, issueNumber) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to delete issue: %w", err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Issue #%d deleted from %s/%s\n", cs.SuccessIcon(), issueNumber, owner, name) + return nil +} + +func runIssueReopen(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + issueNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid issue number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + ios.StartSpinner("Reopening issue...") + stateOpen := gitea.StateOpen + _, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{ + State: &stateOpen, + }) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to reopen issue: %w", err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Issue #%d reopened\n", cs.SuccessIcon(), issueNumber) return nil } diff --git a/cmd/json.go b/cmd/json.go index 21f1b25..2472449 100644 --- a/cmd/json.go +++ b/cmd/json.go @@ -2,11 +2,154 @@ package cmd import ( "encoding/json" - "os" + "fmt" + "strings" + + "github.com/itchyny/gojq" + "github.com/spf13/cobra" ) +// addJSONFlags adds --json, --json-fields, and --jq flags to a command. +// --json is an optional-value string flag: +// - --json (no value) → output all fields as JSON +// - --json title,state → output only those fields (gh-compatible) +// +// --json-fields is kept as a backwards-compatible alias. +func addJSONFlags(cmd *cobra.Command, jsonDesc string) { + f := cmd.Flags() + f.String("json", "", jsonDesc) + f.Lookup("json").NoOptDefVal = " " // space sentinel: flag present with no value + f.String("json-fields", "", "Comma-separated list of JSON fields to include") + f.String("jq", "", "Filter JSON output using a jq expression") +} + +// wantJSON returns true if the user requested JSON output via --json, --json-fields, or --jq. +func wantJSON(cmd *cobra.Command) bool { + if j, _ := cmd.Flags().GetString("json"); j != "" { + return true + } + if jq, _ := cmd.Flags().GetString("jq"); jq != "" { + return true + } + if f, _ := cmd.Flags().GetString("json-fields"); f != "" { + return true + } + return false +} + +// outputJSON writes a value as JSON, respecting --json, --json-fields, and --jq flags. +func outputJSON(cmd *cobra.Command, value any) error { + jsonVal, _ := cmd.Flags().GetString("json") + jsonFields, _ := cmd.Flags().GetString("json-fields") + jqExpr, _ := cmd.Flags().GetString("jq") + + fields := "" + jsonVal = strings.TrimSpace(jsonVal) + if jsonVal != "" { + fields = jsonVal + } else if jsonFields != "" { + fields = jsonFields + } + + return writeJSONFiltered(value, fields, jqExpr) +} + +// writeJSON writes a value as pretty-printed JSON to ios.Out. func writeJSON(value any) error { - enc := json.NewEncoder(os.Stdout) + enc := json.NewEncoder(ios.Out) enc.SetIndent("", " ") return enc.Encode(value) } + +// writeJSONFiltered writes a value as JSON, optionally selecting specific fields +// and/or applying a jq expression. If fields is empty and jqExpr is empty, it +// writes the full value. +func writeJSONFiltered(value any, fields string, jqExpr string) error { + // If no filtering, just write the full JSON. + if fields == "" && jqExpr == "" { + return writeJSON(value) + } + + // Convert value to a generic interface via JSON round-trip so we can + // manipulate it with maps/slices. + raw, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("marshaling JSON: %w", err) + } + + var data any + if err := json.Unmarshal(raw, &data); err != nil { + return fmt.Errorf("unmarshaling JSON: %w", err) + } + + // Apply field selection if specified. + if fields != "" { + fieldList := strings.Split(fields, ",") + for i, f := range fieldList { + fieldList[i] = strings.TrimSpace(f) + } + data = selectFields(data, fieldList) + } + + // Apply jq expression if specified. + if jqExpr != "" { + return applyJQ(data, jqExpr) + } + + return writeJSON(data) +} + +// selectFields filters a JSON value to only include the specified fields. +// Works on both single objects and arrays of objects. +func selectFields(data any, fields []string) any { + switch v := data.(type) { + case []any: + result := make([]any, len(v)) + for i, item := range v { + result[i] = selectFields(item, fields) + } + return result + case map[string]any: + result := make(map[string]any) + for _, field := range fields { + if val, ok := v[field]; ok { + result[field] = val + } + } + return result + default: + return data + } +} + +// applyJQ applies a jq expression to data and writes each output value. +func applyJQ(data any, expr string) error { + query, err := gojq.Parse(expr) + if err != nil { + return fmt.Errorf("invalid jq expression: %w", err) + } + + iter := query.Run(data) + enc := json.NewEncoder(ios.Out) + enc.SetIndent("", " ") + + for { + v, ok := iter.Next() + if !ok { + break + } + if err, isErr := v.(error); isErr { + return fmt.Errorf("jq error: %w", err) + } + // For string values, print raw (no JSON encoding) to match jq behavior. + if s, ok := v.(string); ok { + fmt.Fprintln(ios.Out, s) + } else { + if err := enc.Encode(v); err != nil { + return err + } + } + } + + return nil +} diff --git a/cmd/label.go b/cmd/label.go index b5d1205..83966d8 100644 --- a/cmd/label.go +++ b/cmd/label.go @@ -2,9 +2,7 @@ package cmd import ( "fmt" - "os" "strings" - "text/tabwriter" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" @@ -72,6 +70,9 @@ var labelDeleteCmd = &cobra.Command{ Example: ` # Delete a label fgj label delete bug + # Delete without confirmation + fgj label delete bug -y + # Delete a label from a specific repository fgj label delete bug -R owner/repo`, Args: cobra.ExactArgs(1), @@ -86,20 +87,21 @@ func init() { labelCmd.AddCommand(labelDeleteCmd) labelListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - labelListCmd.Flags().Bool("json", false, "Output as JSON") + addJSONFlags(labelListCmd, "Output as JSON") labelCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") labelCreateCmd.Flags().StringP("color", "c", "", "Label color (hex, e.g. 00ff00)") labelCreateCmd.Flags().StringP("description", "d", "", "Label description") - labelCreateCmd.Flags().Bool("json", false, "Output as JSON") + addJSONFlags(labelCreateCmd, "Output as JSON") labelEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") labelEditCmd.Flags().String("name", "", "New name for the label") labelEditCmd.Flags().StringP("color", "c", "", "New color (hex, e.g. 00ff00)") labelEditCmd.Flags().StringP("description", "d", "", "New description") - labelEditCmd.Flags().Bool("json", false, "Output as JSON") + addJSONFlags(labelEditCmd, "Output as JSON") labelDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + labelDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") } func newLabelClient(cmd *cobra.Command) (*api.Client, string, string, error) { @@ -144,29 +146,28 @@ func runLabelList(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching labels...") labels, _, err := client.ListRepoLabels(owner, name, gitea.ListLabelsOptions{}) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to list labels: %w", err) } - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(labels) + if wantJSON(cmd) { + return outputJSON(cmd, labels) } if len(labels) == 0 { - fmt.Println("No labels found") + fmt.Fprintln(ios.Out, "No labels found") return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "NAME\tCOLOR\tDESCRIPTION\n") + tp := ios.NewTablePrinter() + tp.AddHeader("NAME", "COLOR", "DESCRIPTION") for _, l := range labels { - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", l.Name, l.Color, l.Description) + tp.AddRow(l.Name, l.Color, l.Description) } - _ = w.Flush() - - return nil + return tp.Render() } func runLabelCreate(cmd *cobra.Command, args []string) error { @@ -179,21 +180,23 @@ func runLabelCreate(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Creating label...") label, _, err := client.CreateLabel(owner, name, gitea.CreateLabelOption{ Name: labelName, Color: color, Description: description, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create label: %w", err) } - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(label) + if wantJSON(cmd) { + return outputJSON(cmd, label) } - fmt.Printf("Label created: %s\n", label.Name) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Label created: %s\n", cs.SuccessIcon(), label.Name) return nil } @@ -205,7 +208,9 @@ func runLabelEdit(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching label...") existing, err := findLabelByName(client, owner, name, labelName) + ios.StopSpinner() if err != nil { return err } @@ -233,46 +238,57 @@ func runLabelEdit(cmd *cobra.Command, args []string) error { return fmt.Errorf("no changes specified; use flags like --name, --color, or --description") } + ios.StartSpinner("Updating label...") label, _, err := client.EditLabel(owner, name, existing.ID, opt) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to edit label: %w", err) } - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(label) + if wantJSON(cmd) { + return outputJSON(cmd, label) } - fmt.Printf("Label updated: %s\n", label.Name) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Label updated: %s\n", cs.SuccessIcon(), label.Name) return nil } func runLabelDelete(cmd *cobra.Command, args []string) error { labelName := args[0] + yes, _ := cmd.Flags().GetBool("yes") client, owner, name, err := newLabelClient(cmd) if err != nil { return err } + ios.StartSpinner("Fetching label...") existing, err := findLabelByName(client, owner, name, labelName) + ios.StopSpinner() if err != nil { return err } - fmt.Printf("Are you sure you want to delete label %q? (y/N): ", labelName) - var confirm string - _, _ = fmt.Scanln(&confirm) - if strings.ToLower(confirm) != "y" { - fmt.Println("Aborted") - return nil + if !yes { + confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete label %q?", labelName)) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } } + ios.StartSpinner("Deleting label...") _, err = client.DeleteLabel(owner, name, existing.ID) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to delete label: %w", err) } - fmt.Printf("Label deleted: %s\n", labelName) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Label deleted: %s\n", cs.SuccessIcon(), labelName) return nil } diff --git a/cmd/milestone.go b/cmd/milestone.go index b8d156b..2674a3f 100644 --- a/cmd/milestone.go +++ b/cmd/milestone.go @@ -2,15 +2,14 @@ package cmd import ( "fmt" - "os" "strconv" "strings" - "text/tabwriter" "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) @@ -45,6 +44,9 @@ var milestoneViewCmd = &cobra.Command{ # View by title fgj milestone view "v1.0" + # Open in browser + fgj milestone view "v1.0" --web + # Output as JSON fgj milestone view "v1.0" --json`, Args: cobra.ExactArgs(1), @@ -91,7 +93,10 @@ var milestoneDeleteCmd = &cobra.Command{ fgj milestone delete "v1.0" # Delete by ID - fgj milestone delete 1`, + fgj milestone delete 1 + + # Delete without confirmation + fgj milestone delete "v1.0" -y`, Args: cobra.ExactArgs(1), RunE: runMilestoneDelete, } @@ -106,24 +111,26 @@ func init() { milestoneListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneListCmd.Flags().String("state", "open", "Filter by state: open, closed, all") - milestoneListCmd.Flags().Bool("json", false, "Output milestones as JSON") + addJSONFlags(milestoneListCmd, "Output milestones as JSON") milestoneViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - milestoneViewCmd.Flags().Bool("json", false, "Output milestone as JSON") + addJSONFlags(milestoneViewCmd, "Output milestone as JSON") + milestoneViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") milestoneCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneCreateCmd.Flags().StringP("description", "d", "", "Description of the milestone") milestoneCreateCmd.Flags().String("due", "", "Due date in YYYY-MM-DD format") - milestoneCreateCmd.Flags().Bool("json", false, "Output created milestone as JSON") + addJSONFlags(milestoneCreateCmd, "Output created milestone as JSON") milestoneEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneEditCmd.Flags().String("title", "", "New title for the milestone") milestoneEditCmd.Flags().StringP("description", "d", "", "New description for the milestone") milestoneEditCmd.Flags().String("due", "", "Due date in YYYY-MM-DD format") milestoneEditCmd.Flags().String("state", "", "New state: open or closed") - milestoneEditCmd.Flags().Bool("json", false, "Output updated milestone as JSON") + addJSONFlags(milestoneEditCmd, "Output updated milestone as JSON") milestoneDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + milestoneDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") } // resolveMilestone resolves a title-or-id argument to a milestone. @@ -193,35 +200,40 @@ func runMilestoneList(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid state: %s", state) } + ios.StartSpinner("Fetching milestones...") milestones, _, err := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{ State: stateType, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to list milestones: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(milestones) + if wantJSON(cmd) { + return outputJSON(cmd, milestones) } if len(milestones) == 0 { - fmt.Printf("No %s milestones in %s/%s\n", state, owner, name) + fmt.Fprintf(ios.Out, "No %s milestones in %s/%s\n", state, owner, name) return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "ID\tTITLE\tSTATE\tDUE DATE\tOPEN/CLOSED ISSUES\n") + tp := ios.NewTablePrinter() + tp.AddHeader("ID", "TITLE", "STATE", "DUE DATE", "OPEN/CLOSED ISSUES") for _, ms := range milestones { due := "" if ms.Deadline != nil { due = ms.Deadline.Format("2006-01-02") } - _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d/%d\n", - ms.ID, ms.Title, ms.State, due, ms.OpenIssues, ms.ClosedIssues) + tp.AddRow( + fmt.Sprintf("%d", ms.ID), + ms.Title, + string(ms.State), + due, + fmt.Sprintf("%d/%d", ms.OpenIssues, ms.ClosedIssues), + ) } - _ = w.Flush() - - return nil + return tp.Render() } func runMilestoneView(cmd *cobra.Command, args []string) error { @@ -242,32 +254,45 @@ func runMilestoneView(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching milestone...") ms, err := resolveMilestone(client, owner, name, args[0]) + ios.StopSpinner() if err != nil { return err } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(ms) + if web, _ := cmd.Flags().GetBool("web"); web { + // Milestones don't have HTMLURL in the API, construct it + cfg2, _ := config.Load() + host, _ := cfg2.GetHost("", getDetectedHost()) + url := fmt.Sprintf("https://%s/%s/%s/milestone/%d", host.Hostname, owner, name, ms.ID) + return ios.OpenInBrowser(url) } - fmt.Printf("ID: %d\n", ms.ID) - fmt.Printf("Title: %s\n", ms.Title) - fmt.Printf("State: %s\n", ms.State) + if wantJSON(cmd) { + return outputJSON(cmd, ms) + } + + cs := ios.ColorScheme() + isTTY := ios.IsStdoutTTY() + + fmt.Fprintf(ios.Out, "ID: %d\n", ms.ID) + fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(ms.Title)) + fmt.Fprintf(ios.Out, "State: %s\n", ms.State) if ms.Description != "" { - fmt.Printf("Description: %s\n", ms.Description) + fmt.Fprintf(ios.Out, "Description: %s\n", ms.Description) } if ms.Deadline != nil { - fmt.Printf("Due Date: %s\n", ms.Deadline.Format("2006-01-02")) + fmt.Fprintf(ios.Out, "Due Date: %s\n", ms.Deadline.Format("2006-01-02")) } - fmt.Printf("Open Issues: %d\n", ms.OpenIssues) - fmt.Printf("Closed Issues: %d\n", ms.ClosedIssues) - fmt.Printf("Created: %s\n", ms.Created.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Open Issues: %d\n", ms.OpenIssues) + fmt.Fprintf(ios.Out, "Closed Issues: %d\n", ms.ClosedIssues) + fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(ms.Created, isTTY)) if ms.Updated != nil { - fmt.Printf("Updated: %s\n", ms.Updated.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(*ms.Updated, isTTY)) } if ms.Closed != nil { - fmt.Printf("Closed: %s\n", ms.Closed.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Closed: %s\n", text.FormatDate(*ms.Closed, isTTY)) } return nil @@ -308,16 +333,19 @@ func runMilestoneCreate(cmd *cobra.Command, args []string) error { opt.Deadline = deadline } + ios.StartSpinner("Creating milestone...") ms, _, err := client.CreateMilestone(owner, name, opt) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create milestone: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(ms) + if wantJSON(cmd) { + return outputJSON(cmd, ms) } - fmt.Printf("Milestone created: %s\n", ms.Title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Milestone created: %s\n", cs.SuccessIcon(), ms.Title) return nil } @@ -340,7 +368,9 @@ func runMilestoneEdit(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching milestone...") ms, err := resolveMilestone(client, owner, name, args[0]) + ios.StopSpinner() if err != nil { return err } @@ -389,22 +419,26 @@ func runMilestoneEdit(cmd *cobra.Command, args []string) error { return fmt.Errorf("no changes specified; use flags like --title, --description, --due, or --state") } + ios.StartSpinner("Updating milestone...") updated, _, err := client.EditMilestone(owner, name, ms.ID, opt) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to edit milestone: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(updated) + if wantJSON(cmd) { + return outputJSON(cmd, updated) } - fmt.Printf("Milestone updated: %s\n", updated.Title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Milestone updated: %s\n", cs.SuccessIcon(), updated.Title) return nil } func runMilestoneDelete(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") + yes, _ := cmd.Flags().GetBool("yes") owner, name, err := parseRepo(repo) if err != nil { @@ -421,17 +455,33 @@ func runMilestoneDelete(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching milestone...") ms, err := resolveMilestone(client, owner, name, args[0]) + ios.StopSpinner() if err != nil { return err } + if !yes { + confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete milestone %q?", ms.Title)) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } + } + + ios.StartSpinner("Deleting milestone...") _, err = client.DeleteMilestone(owner, name, ms.ID) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to delete milestone: %w", err) } - fmt.Printf("Milestone deleted: %s\n", ms.Title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Milestone deleted: %s\n", cs.SuccessIcon(), ms.Title) return nil } diff --git a/cmd/pr.go b/cmd/pr.go index a3a92d6..72bbb9f 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -2,14 +2,15 @@ package cmd import ( "fmt" - "os" - "strconv" + "net/http" + "os/exec" "strings" - "text/tabwriter" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + gitpkg "forgejo.zerova.net/sid/fgj-sid/internal/git" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) @@ -24,30 +25,120 @@ var prListCmd = &cobra.Command{ Use: "list [flags]", Short: "List pull requests", Long: "List pull requests in a repository.", - RunE: runPRList, + Example: ` # List open pull requests + fgj pr list + + # List all pull requests for a specific repo + fgj pr list -s all -R owner/repo + + # Output as JSON + fgj pr list --json`, + RunE: runPRList, } var prViewCmd = &cobra.Command{ - Use: "view ", + Use: "view []", Short: "View a pull request", Long: "Display detailed information about a pull request.", - Args: cobra.ExactArgs(1), - RunE: runPRView, + Example: ` # View pull request #5 + fgj pr view 5 + + # View using URL + fgj pr view https://codeberg.org/owner/repo/pulls/5 + + # View PR for current branch + fgj pr view + + # Open in browser + fgj pr view 5 --web + + # View as JSON + fgj pr view 5 --json`, + Args: cobra.MaximumNArgs(1), + RunE: runPRView, } var prCreateCmd = &cobra.Command{ Use: "create", Short: "Create a pull request", Long: "Create a new pull request.", - RunE: runPRCreate, + Example: ` # Create a pull request from feature branch to main + fgj pr create -t "Add login page" -H feature/login + + # Create with body and custom base branch + fgj pr create -t "Fix bug" -b "Closes #42" -H fix/bug -B develop + + # Create and self-assign + fgj pr create -t "Update docs" -H docs/update -a @me`, + RunE: runPRCreate, } var prMergeCmd = &cobra.Command{ Use: "merge ", Short: "Merge a pull request", Long: "Merge a pull request.", - Args: cobra.ExactArgs(1), - RunE: runPRMerge, + Example: ` # Merge pull request #5 + fgj pr merge 5 + + # Squash merge + fgj pr merge 5 --merge-method squash + + # Rebase merge + fgj pr merge 5 --merge-method rebase + + # Merge without confirmation + fgj pr merge 5 -y`, + Args: cobra.ExactArgs(1), + RunE: runPRMerge, +} + +var prCloseCmd = &cobra.Command{ + Use: "close ", + Short: "Close a pull request", + Long: "Close a pull request without merging.", + Example: ` # Close PR #5 + fgj pr close 5 + + # Close with a comment + fgj pr close 5 -c "Won't merge, superseded by #10"`, + Args: cobra.ExactArgs(1), + RunE: runPRClose, +} + +var prReopenCmd = &cobra.Command{ + Use: "reopen ", + Short: "Reopen a pull request", + Long: "Reopen a closed pull request.", + Example: ` # Reopen PR #5 + fgj pr reopen 5`, + Args: cobra.ExactArgs(1), + RunE: runPRReopen, +} + +var prEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit a pull request", + Long: "Edit a pull request's title, body, or metadata.", + Example: ` # Update the title of PR #5 + fgj pr edit 5 -t "Updated title" + + # Add assignees and labels + fgj pr edit 5 --add-assignee user1 --add-label bug + + # Remove a reviewer and set milestone + fgj pr edit 5 --remove-reviewer user2 --milestone "v1.0"`, + Args: cobra.ExactArgs(1), + RunE: runPREdit, +} + +var prCheckoutCmd = &cobra.Command{ + Use: "checkout ", + Short: "Check out a pull request locally", + Long: "Check out the head branch of a pull request.", + Example: ` # Check out PR #5 + fgj pr checkout 5`, + Args: cobra.ExactArgs(1), + RunE: runPRCheckout, } func init() { @@ -56,13 +147,32 @@ func init() { prCmd.AddCommand(prViewCmd) prCmd.AddCommand(prCreateCmd) prCmd.AddCommand(prMergeCmd) + prCmd.AddCommand(prCloseCmd) + prCmd.AddCommand(prReopenCmd) + prCmd.AddCommand(prEditCmd) + prCmd.AddCommand(prCheckoutCmd) + + prCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + prCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing") + + prReopenCmd.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().Bool("json", false, "Output pull requests as JSON") + prListCmd.Flags().IntP("limit", "L", 30, "Maximum number of results") + prListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee username") + prListCmd.Flags().String("author", "", "Filter by author username") + prListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names") + prListCmd.Flags().StringP("search", "S", "", "Search keyword filter") + prListCmd.Flags().Bool("draft", false, "Filter by draft status") + prListCmd.Flags().String("head", "", "Filter by head branch") + prListCmd.Flags().String("base", "", "Filter by base branch") + prListCmd.Flags().BoolP("web", "w", false, "Open list in web browser") + addJSONFlags(prListCmd, "Output pull requests as JSON") prViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - prViewCmd.Flags().Bool("json", false, "Output pull request as JSON") + addJSONFlags(prViewCmd, "Output pull request as JSON") + prViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") prCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prCreateCmd.Flags().StringP("title", "t", "", "Title for the pull request") @@ -70,14 +180,43 @@ func init() { prCreateCmd.Flags().StringP("head", "H", "", "Head branch") prCreateCmd.Flags().StringP("base", "B", "", "Base branch (default: main)") prCreateCmd.Flags().StringSliceP("assignee", "a", []string{}, "Assign people by their login. Use \"@me\" to self-assign.") + prCreateCmd.Flags().BoolP("draft", "d", false, "Create as draft pull request") + prCreateCmd.Flags().StringSliceP("reviewer", "r", nil, "Request reviewers by username") + prCreateCmd.Flags().StringSliceP("label", "l", nil, "Add labels by name") + prCreateCmd.Flags().StringP("milestone", "m", "", "Set milestone by name") prMergeCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prMergeCmd.Flags().String("merge-method", "merge", "Merge method: merge, rebase, squash") + prMergeCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + prMergeCmd.Flags().BoolP("delete-branch", "d", false, "Delete the branch after merge") + prMergeCmd.Flags().Bool("auto", false, "Merge automatically when checks succeed") + + prCheckoutCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + + prEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + prEditCmd.Flags().StringP("title", "t", "", "New title for the pull request") + prEditCmd.Flags().StringP("body", "b", "", "New body for the pull request") + prEditCmd.Flags().StringP("base", "B", "", "New base branch for the pull request") + prEditCmd.Flags().StringSlice("add-assignee", nil, "Assignees to add (login names)") + prEditCmd.Flags().StringSlice("remove-assignee", nil, "Assignees to remove (login names)") + prEditCmd.Flags().StringSlice("add-label", nil, "Labels to add (by name)") + prEditCmd.Flags().StringSlice("remove-label", nil, "Labels to remove (by name)") + prEditCmd.Flags().StringSlice("add-reviewer", nil, "Reviewers to add (login names)") + prEditCmd.Flags().StringSlice("remove-reviewer", nil, "Reviewers to remove (login names)") + prEditCmd.Flags().String("milestone", "", "Milestone name to set (empty string to clear)") } func runPRList(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") state, _ := cmd.Flags().GetString("state") + limit, _ := cmd.Flags().GetInt("limit") + assignee, _ := cmd.Flags().GetString("assignee") + author, _ := cmd.Flags().GetString("author") + labels, _ := cmd.Flags().GetStringSlice("label") + search, _ := cmd.Flags().GetString("search") + draft, _ := cmd.Flags().GetBool("draft") + head, _ := cmd.Flags().GetString("head") + base, _ := cmd.Flags().GetString("base") owner, name, err := parseRepo(repo) if err != nil { @@ -94,6 +233,10 @@ func runPRList(cmd *cobra.Command, args []string) error { return err } + if web, _ := cmd.Flags().GetBool("web"); web { + return ios.OpenInBrowser(fmt.Sprintf("https://%s/%s/%s/pulls", client.Hostname(), owner, name)) + } + var stateType gitea.StateType switch strings.ToLower(state) { case "open": @@ -106,38 +249,113 @@ func runPRList(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid state: %s", state) } - prs, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{ - State: stateType, - }) - if err != nil { - return fmt.Errorf("failed to list pull requests: %w", err) - } + needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != "" - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(prs) + ios.StartSpinner("Fetching pull requests...") + var prs []*gitea.PullRequest + if needsClientFilter { + page := 1 + for { + batch, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{ + State: stateType, + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to list pull requests: %w", err) + } + prs = append(prs, batch...) + if len(batch) < 50 { + break + } + page++ + } + prs = filterPRs(prs, author, assignee, labels, search, draft, head, base) + if len(prs) > limit { + prs = prs[:limit] + } + } else { + prs, _, err = client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{ + State: stateType, + ListOptions: gitea.ListOptions{PageSize: limit}, + }) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to list pull requests: %w", err) + } + } + ios.StopSpinner() + + if wantJSON(cmd) { + return outputJSON(cmd, prs) } if len(prs) == 0 { - fmt.Printf("No %s pull requests in %s/%s\n", state, owner, name) + fmt.Fprintf(ios.Out, "No %s pull requests in %s/%s\n", state, owner, name) return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tBRANCH\tSTATE\n") + tp := ios.NewTablePrinter() + tp.AddHeader("NUMBER", "TITLE", "BRANCH", "STATE") for _, pr := range prs { - _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", pr.Index, pr.Title, pr.Head.Ref, pr.State) + tp.AddRow(fmt.Sprintf("#%d", pr.Index), pr.Title, pr.Head.Ref, string(pr.State)) } - _ = w.Flush() + return tp.Render() +} - return nil +func filterPRs(prs []*gitea.PullRequest, author, assignee string, labels []string, search string, draft bool, head, base string) []*gitea.PullRequest { + var result []*gitea.PullRequest + for _, pr := range prs { + if author != "" && !strings.EqualFold(pr.Poster.UserName, author) { + continue + } + if assignee != "" { + found := false + for _, a := range pr.Assignees { + if strings.EqualFold(a.UserName, assignee) { + found = true + break + } + } + if !found { + continue + } + } + if len(labels) > 0 { + prLabelNames := make(map[string]bool) + for _, l := range pr.Labels { + prLabelNames[strings.ToLower(l.Name)] = true + } + allFound := true + for _, label := range labels { + if !prLabelNames[strings.ToLower(label)] { + allFound = false + break + } + } + if !allFound { + continue + } + } + if search != "" && !strings.Contains(strings.ToLower(pr.Title), strings.ToLower(search)) { + continue + } + if draft && !pr.Draft { + continue + } + if head != "" && !strings.EqualFold(pr.Head.Ref, head) { + continue + } + if base != "" && !strings.EqualFold(pr.Base.Ref, base) { + continue + } + result = append(result, pr) + } + return result } func runPRView(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") - prNumber, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid pull request number: %w", err) - } owner, name, err := parseRepo(repo) if err != nil { @@ -154,24 +372,77 @@ func runPRView(cmd *cobra.Command, args []string) error { return err } + var prNumber int64 + if len(args) == 0 { + // Try to find PR for current branch + branch, branchErr := gitpkg.GetCurrentBranch() + if branchErr != nil { + return fmt.Errorf("no pull request number specified and could not detect current branch: %w", branchErr) + } + + ios.StartSpinner("Finding pull request for branch...") + prs, _, listErr := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{ + State: gitea.StateOpen, + }) + ios.StopSpinner() + if listErr != nil { + return fmt.Errorf("failed to list pull requests: %w", listErr) + } + + var found bool + for _, pr := range prs { + if pr.Head.Ref == branch { + prNumber = pr.Index + found = true + break + } + } + if !found { + return fmt.Errorf("no open pull request found for branch %q", branch) + } + } else { + prNumber, err = parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + } + + ios.StartSpinner("Fetching pull request...") pr, _, err := client.GetPullRequest(owner, name, prNumber) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to get pull request: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(pr) + if web, _ := cmd.Flags().GetBool("web"); web { + return ios.OpenInBrowser(pr.HTMLURL) } - fmt.Printf("Pull Request #%d\n", pr.Index) - fmt.Printf("Title: %s\n", pr.Title) - fmt.Printf("State: %s\n", pr.State) - fmt.Printf("Author: %s\n", pr.Poster.UserName) - fmt.Printf("Branch: %s -> %s\n", pr.Head.Ref, pr.Base.Ref) - fmt.Printf("Created: %s\n", pr.Created.Format("2006-01-02 15:04:05")) - fmt.Printf("Updated: %s\n", pr.Updated.Format("2006-01-02 15:04:05")) + if wantJSON(cmd) { + return outputJSON(cmd, pr) + } + + if err := ios.StartPager(); err != nil { + fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err) + } + defer ios.StopPager() + + cs := ios.ColorScheme() + isTTY := ios.IsStdoutTTY() + + fmt.Fprintf(ios.Out, "%s Pull Request #%d\n", cs.Bold(""), pr.Index) + fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(pr.Title)) + fmt.Fprintf(ios.Out, "State: %s\n", pr.State) + fmt.Fprintf(ios.Out, "Author: %s\n", pr.Poster.UserName) + fmt.Fprintf(ios.Out, "Branch: %s -> %s\n", pr.Head.Ref, pr.Base.Ref) + if pr.Created != nil { + fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(*pr.Created, isTTY)) + } + if pr.Updated != nil { + fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(*pr.Updated, isTTY)) + } if pr.Body != "" { - fmt.Printf("\n%s\n", pr.Body) + fmt.Fprintf(ios.Out, "\n%s\n", pr.Body) } return nil @@ -184,22 +455,44 @@ func runPRCreate(cmd *cobra.Command, args []string) error { head, _ := cmd.Flags().GetString("head") base, _ := cmd.Flags().GetString("base") assignees, _ := cmd.Flags().GetStringSlice("assignee") - - if base == "" { - base = "main" - } + draft, _ := cmd.Flags().GetBool("draft") + reviewers, _ := cmd.Flags().GetStringSlice("reviewer") + labelNames, _ := cmd.Flags().GetStringSlice("label") + milestoneName, _ := cmd.Flags().GetString("milestone") owner, name, err := parseRepo(repo) if err != nil { return err } - if title == "" { - return fmt.Errorf("title is required") + // Interactive mode: prompt for missing fields when TTY + if title == "" && ios.IsStdinTTY() { + title, err = promptLine("Title: ") + if err != nil { + return err + } + if title == "" { + return fmt.Errorf("title is required") + } + } else if title == "" { + return fmt.Errorf("title is required (use -t flag)") } + if head == "" && ios.IsStdinTTY() { + // Default to current branch + branch, branchErr := gitpkg.GetCurrentBranch() + if branchErr == nil { + head = branch + fmt.Fprintf(ios.ErrOut, "Using current branch %q as head\n", head) + } else { + head, err = promptLine("Head branch: ") + if err != nil { + return err + } + } + } if head == "" { - return fmt.Errorf("head branch is required") + return fmt.Errorf("head branch is required (use -H flag)") } cfg, err := config.Load() @@ -212,6 +505,16 @@ func runPRCreate(cmd *cobra.Command, args []string) error { return err } + if base == "" { + ios.StartSpinner("Fetching repository info...") + repoInfo, _, err := client.GetRepo(owner, name) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to get repository info: %w", err) + } + base = repoInfo.DefaultBranch + } + // Resolve @me in assignees resolvedAssignees := make([]string, 0, len(assignees)) for _, assignee := range assignees { @@ -226,19 +529,62 @@ func runPRCreate(cmd *cobra.Command, args []string) error { } } + // Resolve label names to IDs + var labelIDs []int64 + if len(labelNames) > 0 { + labelIDs, err = resolveLabelIDs(client, owner, name, labelNames) + if err != nil { + return err + } + } + + // Resolve milestone name to ID + var milestoneID int64 + if milestoneName != "" { + milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{}) + if msErr != nil { + return fmt.Errorf("failed to list milestones: %w", msErr) + } + found := false + for _, ms := range milestones { + if ms.Title == milestoneName { + milestoneID = ms.ID + found = true + break + } + } + if !found { + return fmt.Errorf("milestone not found: %s", milestoneName) + } + } + + ios.StartSpinner("Creating pull request...") pr, _, err := client.CreatePullRequest(owner, name, gitea.CreatePullRequestOption{ Title: title, Body: body, Head: head, Base: base, Assignees: resolvedAssignees, + Reviewers: reviewers, + Labels: labelIDs, + Milestone: milestoneID, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create pull request: %w", err) } - fmt.Printf("Pull request created: #%d\n", pr.Index) - fmt.Printf("View at: %s\n", pr.HTMLURL) + // Set draft status via raw API if needed + if draft { + _, draftErr := client.DoJSON("PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, name, pr.Index), map[string]any{"draft": true}, nil) + if draftErr != nil { + return fmt.Errorf("failed to set pull request as draft: %w", draftErr) + } + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Pull request created: #%d\n", cs.SuccessIcon(), pr.Index) + fmt.Fprintf(ios.Out, "View at: %s\n", pr.HTMLURL) return nil } @@ -246,7 +592,11 @@ func runPRCreate(cmd *cobra.Command, args []string) error { func runPRMerge(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") mergeMethod, _ := cmd.Flags().GetString("merge-method") - prNumber, err := strconv.ParseInt(args[0], 10, 64) + yes, _ := cmd.Flags().GetBool("yes") + deleteBranch, _ := cmd.Flags().GetBool("delete-branch") + autoMerge, _ := cmd.Flags().GetBool("auto") + + prNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid pull request number: %w", err) } @@ -278,14 +628,432 @@ func runPRMerge(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid merge method: %s", mergeMethod) } + if !yes { + confirmed, err := ios.ConfirmAction(fmt.Sprintf("Merge pull request #%d via %s?", prNumber, mergeMethod)) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } + } + + ios.StartSpinner("Merging pull request...") _, _, err = client.MergePullRequest(owner, name, prNumber, gitea.MergePullRequestOption{ - Style: method, + Style: method, + DeleteBranchAfterMerge: deleteBranch, + MergeWhenChecksSucceed: autoMerge, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to merge pull request: %w", err) } - fmt.Printf("Pull request #%d merged successfully\n", prNumber) + cs := ios.ColorScheme() + if autoMerge { + fmt.Fprintf(ios.Out, "%s Auto-merge enabled for PR #%d\n", cs.SuccessIcon(), prNumber) + } else { + fmt.Fprintf(ios.Out, "%s Pull request #%d merged successfully\n", cs.SuccessIcon(), prNumber) + } + + return nil +} + +func runPRClose(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + commentBody, _ := cmd.Flags().GetString("comment") + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + if commentBody != "" { + ios.StartSpinner("Adding comment...") + _, _, err = client.CreateIssueComment(owner, name, prNumber, gitea.CreateIssueCommentOption{ + Body: commentBody, + }) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to create comment: %w", err) + } + } + + ios.StartSpinner("Closing pull request...") + stateClosed := gitea.StateClosed + _, _, err = client.EditPullRequest(owner, name, prNumber, gitea.EditPullRequestOption{ + State: &stateClosed, + }) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to close pull request: %w", err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Pull request #%d closed\n", cs.SuccessIcon(), prNumber) + + return nil +} + +func runPRReopen(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + ios.StartSpinner("Reopening pull request...") + stateOpen := gitea.StateOpen + _, _, err = client.EditPullRequest(owner, name, prNumber, gitea.EditPullRequestOption{ + State: &stateOpen, + }) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to reopen pull request: %w", err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Pull request #%d reopened\n", cs.SuccessIcon(), prNumber) + + return nil +} + +func runPRCheckout(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + ios.StartSpinner("Fetching pull request...") + pr, _, err := client.GetPullRequest(owner, name, prNumber) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to get pull request: %w", err) + } + + headBranch := pr.Head.Ref + headRepo := pr.Head.Repository + + // Determine if same-repo or cross-repo PR + isSameRepo := headRepo == nil || headRepo.FullName == fmt.Sprintf("%s/%s", owner, name) + + if isSameRepo { + // Same repo: fetch and checkout + ios.StartSpinner("Checking out branch...") + + gitFetch := exec.Command("git", "fetch", "origin", headBranch) + gitFetch.Stdout = ios.Out + gitFetch.Stderr = ios.ErrOut + if err := gitFetch.Run(); err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to fetch branch: %w", err) + } + + // Try to checkout existing branch first + gitCheckout := exec.Command("git", "checkout", headBranch) + gitCheckout.Stdout = ios.Out + gitCheckout.Stderr = ios.ErrOut + if err := gitCheckout.Run(); err != nil { + // Branch doesn't exist locally, create it tracking remote + gitCheckout = exec.Command("git", "checkout", "-b", headBranch, "origin/"+headBranch) + gitCheckout.Stdout = ios.Out + gitCheckout.Stderr = ios.ErrOut + if err := gitCheckout.Run(); err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to checkout branch: %w", err) + } + } else { + // Branch existed, pull latest + gitPull := exec.Command("git", "pull") + gitPull.Stdout = ios.Out + gitPull.Stderr = ios.ErrOut + _ = gitPull.Run() + } + ios.StopSpinner() + } else { + // Cross-repo (fork): add remote and checkout + forkOwner := headRepo.Owner.UserName + forkCloneURL := headRepo.CloneURL + + ios.StartSpinner("Checking out branch from fork...") + + // Add fork as remote (ignore error if already exists) + gitRemoteAdd := exec.Command("git", "remote", "add", forkOwner, forkCloneURL) + gitRemoteAdd.Stdout = ios.Out + gitRemoteAdd.Stderr = ios.ErrOut + _ = gitRemoteAdd.Run() // ignore error if remote already exists + + // Fetch from fork + gitFetch := exec.Command("git", "fetch", forkOwner) + gitFetch.Stdout = ios.Out + gitFetch.Stderr = ios.ErrOut + if err := gitFetch.Run(); err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to fetch from fork: %w", err) + } + + // Checkout the branch + gitCheckout := exec.Command("git", "checkout", "-b", headBranch, forkOwner+"/"+headBranch) + gitCheckout.Stdout = ios.Out + gitCheckout.Stderr = ios.ErrOut + if err := gitCheckout.Run(); err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to checkout branch: %w", err) + } + ios.StopSpinner() + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Checked out PR #%d on branch %q\n", cs.SuccessIcon(), prNumber, headBranch) + return nil +} + +func runPREdit(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + // Check that at least one flag was provided + anyChanged := false + for _, flag := range []string{"title", "body", "base", "add-assignee", "remove-assignee", + "add-label", "remove-label", "add-reviewer", "remove-reviewer", "milestone"} { + if cmd.Flags().Changed(flag) { + anyChanged = true + break + } + } + if !anyChanged { + return fmt.Errorf("at least one of --title, --body, --base, --add-assignee, --remove-assignee, " + + "--add-label, --remove-label, --add-reviewer, --remove-reviewer, or --milestone must be provided") + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + // Build EditPullRequestOption from changed flags + editOpt := gitea.EditPullRequestOption{} + needsEditCall := false + + if cmd.Flags().Changed("title") { + title, _ := cmd.Flags().GetString("title") + editOpt.Title = title + needsEditCall = true + } + + if cmd.Flags().Changed("body") { + body, _ := cmd.Flags().GetString("body") + editOpt.Body = &body + needsEditCall = true + } + + if cmd.Flags().Changed("base") { + base, _ := cmd.Flags().GetString("base") + editOpt.Base = base + needsEditCall = true + } + + // Handle milestone + if cmd.Flags().Changed("milestone") { + milestoneName, _ := cmd.Flags().GetString("milestone") + if milestoneName == "" { + // Clear milestone by setting to 0 + editOpt.Milestone = 0 + } else { + milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{}) + if msErr != nil { + return fmt.Errorf("failed to list milestones: %w", msErr) + } + var milestoneID int64 + for _, ms := range milestones { + if ms.Title == milestoneName { + milestoneID = ms.ID + break + } + } + if milestoneID == 0 { + return fmt.Errorf("milestone not found: %s", milestoneName) + } + editOpt.Milestone = milestoneID + } + needsEditCall = true + } + + // Handle assignees (add/remove requires fetching current PR) + addAssignees, _ := cmd.Flags().GetStringSlice("add-assignee") + removeAssignees, _ := cmd.Flags().GetStringSlice("remove-assignee") + + if len(addAssignees) > 0 || len(removeAssignees) > 0 { + ios.StartSpinner("Fetching pull request...") + pr, _, prErr := client.GetPullRequest(owner, name, prNumber) + ios.StopSpinner() + if prErr != nil { + return fmt.Errorf("failed to get pull request: %w", prErr) + } + + // Build current assignee set + assigneeSet := make(map[string]bool) + for _, a := range pr.Assignees { + assigneeSet[a.UserName] = true + } + + // Add new assignees + for _, a := range addAssignees { + assigneeSet[a] = true + } + + // Remove assignees + for _, a := range removeAssignees { + delete(assigneeSet, a) + } + + // Convert back to slice + newAssignees := make([]string, 0, len(assigneeSet)) + for a := range assigneeSet { + newAssignees = append(newAssignees, a) + } + + editOpt.Assignees = newAssignees + needsEditCall = true + } + + ios.StartSpinner("Updating pull request...") + + // Perform the edit API call if needed + if needsEditCall { + _, _, err = client.EditPullRequest(owner, name, prNumber, editOpt) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to edit pull request: %w", err) + } + } + + // Handle labels + addLabelNames, _ := cmd.Flags().GetStringSlice("add-label") + removeLabelNames, _ := cmd.Flags().GetStringSlice("remove-label") + + if len(addLabelNames) > 0 { + labelIDs, labelErr := resolveLabelIDs(client, owner, name, addLabelNames) + if labelErr != nil { + ios.StopSpinner() + return labelErr + } + _, _, err = client.AddIssueLabels(owner, name, prNumber, gitea.IssueLabelsOption{ + Labels: labelIDs, + }) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to add labels: %w", err) + } + } + + if len(removeLabelNames) > 0 { + labelIDs, labelErr := resolveLabelIDs(client, owner, name, removeLabelNames) + if labelErr != nil { + ios.StopSpinner() + return labelErr + } + for _, labelID := range labelIDs { + _, err = client.DeleteIssueLabel(owner, name, prNumber, labelID) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to remove label %d: %w", labelID, err) + } + } + } + + // Handle reviewers + addReviewers, _ := cmd.Flags().GetStringSlice("add-reviewer") + removeReviewers, _ := cmd.Flags().GetStringSlice("remove-reviewer") + + if len(addReviewers) > 0 { + reviewerReq := map[string][]string{ + "reviewers": addReviewers, + } + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", owner, name, prNumber) + _, err := client.DoJSON(http.MethodPost, endpoint, reviewerReq, nil) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to add reviewers: %w", err) + } + } + + if len(removeReviewers) > 0 { + reviewerReq := map[string][]string{ + "reviewers": removeReviewers, + } + endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", owner, name, prNumber) + _, err := client.DoJSON(http.MethodDelete, endpoint, reviewerReq, nil) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to remove reviewers: %w", err) + } + } + + ios.StopSpinner() + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Pull request #%d updated\n", cs.SuccessIcon(), prNumber) return nil } diff --git a/cmd/pr_checks.go b/cmd/pr_checks.go new file mode 100644 index 0000000..4198668 --- /dev/null +++ b/cmd/pr_checks.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" + "forgejo.zerova.net/sid/fgj-sid/internal/api" + "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/iostreams" + "github.com/spf13/cobra" +) + +var prChecksCmd = &cobra.Command{ + Use: "checks ", + Short: "Show CI status checks for a pull request", + Long: "Show the status of CI checks for a pull request.", + Example: ` # Show checks for PR #5 + fgj pr checks 5 + + # Output as JSON + fgj pr checks 5 --json`, + Args: cobra.ExactArgs(1), + RunE: runPRChecks, +} + +func init() { + prCmd.AddCommand(prChecksCmd) + prChecksCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + addJSONFlags(prChecksCmd, "Output checks as JSON") +} + +func runPRChecks(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + ios.StartSpinner("Fetching pull request...") + pr, _, err := client.GetPullRequest(owner, name, prNumber) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to get pull request: %w", err) + } + + statuses, _, err := client.ListStatuses(owner, name, pr.Head.Sha, gitea.ListStatusesOption{}) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to get commit statuses: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, statuses) + } + + if len(statuses) == 0 { + fmt.Fprintf(ios.Out, "No status checks found for PR #%d\n", prNumber) + return nil + } + + cs := ios.ColorScheme() + tp := ios.NewTablePrinter() + tp.AddHeader("STATUS", "CONTEXT", "DESCRIPTION") + for _, s := range statuses { + status := formatCheckStatus(s.State, cs) + tp.AddRow(status, s.Context, s.Description) + } + return tp.Render() +} + +func formatCheckStatus(state gitea.StatusState, cs *iostreams.ColorScheme) string { + switch state { + case gitea.StatusSuccess: + return cs.Green("pass") + case gitea.StatusFailure, gitea.StatusError: + return cs.Red("fail") + case gitea.StatusPending: + return cs.Yellow("pending") + case gitea.StatusWarning: + return cs.Yellow("warn") + default: + return string(state) + } +} diff --git a/cmd/pr_diff.go b/cmd/pr_diff.go index f11ec89..cee3fce 100644 --- a/cmd/pr_diff.go +++ b/cmd/pr_diff.go @@ -2,14 +2,11 @@ package cmd import ( "fmt" - "os" - "strconv" "strings" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" "github.com/spf13/cobra" - "golang.org/x/term" ) var prDiffCmd = &cobra.Command{ @@ -46,7 +43,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error { nameOnly, _ := cmd.Flags().GetBool("name-only") stat, _ := cmd.Flags().GetBool("stat") - prNumber, err := strconv.ParseInt(args[0], 10, 64) + prNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid pull request number: %w", err) } @@ -69,7 +66,9 @@ func runPRDiff(cmd *cobra.Command, args []string) error { diffURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/pulls/%d.diff", client.Hostname(), owner, name, prNumber) + ios.StartSpinner("Fetching diff...") diff, err := client.GetRawLog(diffURL) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to get pull request diff: %w", err) } @@ -82,12 +81,18 @@ func runPRDiff(cmd *cobra.Command, args []string) error { return printDiffStat(diff) } + // Start pager for diffs + if err := ios.StartPager(); err != nil { + fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err) + } + defer ios.StopPager() + useColor := shouldColorize(colorMode) if useColor { return printColorizedDiff(diff) } - fmt.Print(diff) + fmt.Fprint(ios.Out, diff) return nil } @@ -99,7 +104,7 @@ func shouldColorize(mode string) bool { case "never": return false default: // "auto" - return term.IsTerminal(int(os.Stdout.Fd())) + return ios.ColorEnabled() } } @@ -111,7 +116,7 @@ func printNameOnly(diff string) error { name := strings.TrimPrefix(line, "+++ b/") if name != "" && !seen[name] { seen[name] = true - fmt.Println(name) + fmt.Fprintln(ios.Out, name) } } } @@ -120,9 +125,9 @@ func printNameOnly(diff string) error { // fileStat holds per-file diff statistics. type fileStat struct { - name string - additions int - deletions int + name string + additions int + deletions int } // printDiffStat parses the diff and prints a diffstat summary. @@ -165,10 +170,12 @@ func printDiffStat(diff string) error { } if len(stats) == 0 { - fmt.Println("0 files changed") + fmt.Fprintln(ios.Out, "0 files changed") return nil } + cs := ios.ColorScheme() + // Find the longest file name for alignment maxNameLen := 0 maxChanges := 0 @@ -210,44 +217,36 @@ func printDiffStat(diff string) error { scaledDel = 1 } } - bar = strings.Repeat("+", scaledAdd) + strings.Repeat("-", scaledDel) + bar = cs.Green(strings.Repeat("+", scaledAdd)) + cs.Red(strings.Repeat("-", scaledDel)) } - fmt.Printf(" %-*s | %4d %s\n", maxNameLen, s.name, total, bar) + fmt.Fprintf(ios.Out, " %-*s | %4d %s\n", maxNameLen, s.name, total, bar) } - fmt.Printf(" %d file", len(stats)) + fmt.Fprintf(ios.Out, " %d file", len(stats)) if len(stats) != 1 { - fmt.Print("s") + fmt.Fprint(ios.Out, "s") } - fmt.Printf(" changed, %d insertion", totalAdditions) + fmt.Fprintf(ios.Out, " changed, %d insertion", totalAdditions) if totalAdditions != 1 { - fmt.Print("s") + fmt.Fprint(ios.Out, "s") } - fmt.Printf("(+), %d deletion", totalDeletions) + fmt.Fprintf(ios.Out, "(+), %d deletion", totalDeletions) if totalDeletions != 1 { - fmt.Print("s") + fmt.Fprint(ios.Out, "s") } - fmt.Println("(-)") + fmt.Fprintln(ios.Out, "(-)") return nil } -// ANSI color codes for diff output. -const ( - colorReset = "\033[0m" - colorRed = "\033[31m" - colorGreen = "\033[32m" - colorCyan = "\033[36m" - colorBold = "\033[1m" -) - -// printColorizedDiff prints the diff with ANSI color codes. +// printColorizedDiff prints the diff with ANSI color codes using ColorScheme. func printColorizedDiff(diff string) error { + cs := ios.ColorScheme() for _, line := range strings.Split(diff, "\n") { switch { case strings.HasPrefix(line, "diff --git "): - fmt.Println(colorBold + line + colorReset) + fmt.Fprintln(ios.Out, cs.Bold(line)) case strings.HasPrefix(line, "index "), strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "), @@ -256,15 +255,15 @@ func printColorizedDiff(diff string) error { strings.HasPrefix(line, "similarity index"), strings.HasPrefix(line, "rename from"), strings.HasPrefix(line, "rename to"): - fmt.Println(colorBold + line + colorReset) + fmt.Fprintln(ios.Out, cs.Bold(line)) case strings.HasPrefix(line, "@@"): - fmt.Println(colorCyan + line + colorReset) + fmt.Fprintln(ios.Out, cs.Cyan(line)) case strings.HasPrefix(line, "+"): - fmt.Println(colorGreen + line + colorReset) + fmt.Fprintln(ios.Out, cs.Green(line)) case strings.HasPrefix(line, "-"): - fmt.Println(colorRed + line + colorReset) + fmt.Fprintln(ios.Out, cs.Red(line)) default: - fmt.Println(line) + fmt.Fprintln(ios.Out, line) } } return nil diff --git a/cmd/pr_review.go b/cmd/pr_review.go index d64095d..ea370bf 100644 --- a/cmd/pr_review.go +++ b/cmd/pr_review.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "os" - "strconv" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" @@ -57,7 +56,7 @@ func init() { prCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prCommentCmd.Flags().StringP("body", "b", "", "Comment body") prCommentCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)") - prCommentCmd.Flags().Bool("json", false, "Output created comment as JSON") + addJSONFlags(prCommentCmd, "Output created comment as JSON") prReviewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prReviewCmd.Flags().BoolP("approve", "a", false, "Approve the pull request") @@ -65,7 +64,7 @@ func init() { prReviewCmd.Flags().BoolP("comment", "c", false, "Submit as a review comment") prReviewCmd.Flags().StringP("body", "b", "", "Review body/message") prReviewCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)") - prReviewCmd.Flags().Bool("json", false, "Output created review as JSON") + addJSONFlags(prReviewCmd, "Output created review as JSON") } // readBody resolves the body text from --body and --body-file flags. @@ -98,7 +97,7 @@ func readBody(cmd *cobra.Command) (string, error) { func runPRComment(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") - prNumber, err := strconv.ParseInt(args[0], 10, 64) + prNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid pull request number: %w", err) } @@ -127,19 +126,22 @@ func runPRComment(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Adding comment...") comment, _, err := client.CreateIssueComment(owner, name, prNumber, gitea.CreateIssueCommentOption{ Body: body, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create comment: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(comment) + if wantJSON(cmd) { + return outputJSON(cmd, comment) } - fmt.Printf("Comment added to PR #%d\n", prNumber) - fmt.Printf("View at: %s\n", comment.HTMLURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Comment added to PR #%d\n", cs.SuccessIcon(), prNumber) + fmt.Fprintf(ios.Out, "View at: %s\n", comment.HTMLURL) return nil } @@ -150,7 +152,7 @@ func runPRReview(cmd *cobra.Command, args []string) error { requestChanges, _ := cmd.Flags().GetBool("request-changes") commentReview, _ := cmd.Flags().GetBool("comment") - prNumber, err := strconv.ParseInt(args[0], 10, 64) + prNumber, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid pull request number: %w", err) } @@ -208,21 +210,24 @@ func runPRReview(cmd *cobra.Command, args []string) error { action = "reviewed with comment" } + ios.StartSpinner("Submitting review...") review, _, err := client.CreatePullReview(owner, name, prNumber, gitea.CreatePullReviewOptions{ State: state, Body: body, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create review: %w", err) } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(review) + if wantJSON(cmd) { + return outputJSON(cmd, review) } - fmt.Printf("PR #%d %s\n", prNumber, action) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s PR #%d %s\n", cs.SuccessIcon(), prNumber, action) if review.HTMLURL != "" { - fmt.Printf("View at: %s\n", review.HTMLURL) + fmt.Fprintf(ios.Out, "View at: %s\n", review.HTMLURL) } return nil diff --git a/cmd/release.go b/cmd/release.go index cfc9d0a..7f3ffd4 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -3,14 +3,15 @@ package cmd import ( "fmt" "os" + "path" "path/filepath" "strings" - "text/tabwriter" "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) @@ -25,39 +26,98 @@ var releaseListCmd = &cobra.Command{ Use: "list", Short: "List releases", Long: "List releases in a repository.", - RunE: runReleaseList, + Example: ` # List releases + fgj release list + + # List only draft releases + fgj release list --draft + + # Output as JSON with a custom limit + fgj release list --json --limit 10`, + RunE: runReleaseList, } var releaseViewCmd = &cobra.Command{ Use: "view ", Short: "View a release", Long: "Display detailed information about a release.", - Args: cobra.ExactArgs(1), - RunE: runReleaseView, + Example: ` # View a release by tag + fgj release view v1.0.0 + + # View the latest release + fgj release view latest + + # Open in browser + fgj release view v1.0.0 --web + + # Output as JSON + fgj release view v1.0.0 --json`, + Args: cobra.ExactArgs(1), + RunE: runReleaseView, } var releaseCreateCmd = &cobra.Command{ Use: "create [files...]", Short: "Create a release", Long: "Create a new release and optionally upload assets.", - Args: cobra.MinimumNArgs(1), - RunE: runReleaseCreate, + Example: ` # Create a release + fgj release create v1.0.0 + + # Create with title and notes + fgj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements" + + # Create a draft prerelease with assets + fgj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz + + # Create from release notes file + fgj release create v1.0.0 -F CHANGELOG.md`, + Args: cobra.MinimumNArgs(1), + RunE: runReleaseCreate, } var releaseUploadCmd = &cobra.Command{ Use: "upload ", Short: "Upload release assets", Long: "Upload assets to an existing release.", - Args: cobra.MinimumNArgs(2), - RunE: runReleaseUpload, + Example: ` # Upload assets to a release + fgj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64 + + # Upload to the latest release, overwriting existing assets + fgj release upload latest build/output.zip --clobber`, + Args: cobra.MinimumNArgs(2), + RunE: runReleaseUpload, +} + +var releaseDownloadCmd = &cobra.Command{ + Use: "download ", + Short: "Download release assets", + Long: "Download assets from a release.", + Example: ` # Download all assets from a release + fgj release download v1.0.0 + + # Download to a specific directory + fgj release download v1.0.0 -D ./downloads + + # Download a specific asset by name pattern + fgj release download v1.0.0 -p "*.tar.gz"`, + Args: cobra.ExactArgs(1), + RunE: runReleaseDownload, } var releaseDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a release", Long: "Delete a release by tag, keeping its Git tag intact.", - Args: cobra.ExactArgs(1), - RunE: runReleaseDelete, + Example: ` # Delete a release by tag + fgj release delete v1.0.0 + + # Delete the latest release + fgj release delete latest + + # Delete without confirmation + fgj release delete v1.0.0 -y`, + Args: cobra.ExactArgs(1), + RunE: runReleaseDelete, } func init() { @@ -66,16 +126,18 @@ func init() { releaseCmd.AddCommand(releaseViewCmd) releaseCmd.AddCommand(releaseCreateCmd) releaseCmd.AddCommand(releaseUploadCmd) + releaseCmd.AddCommand(releaseDownloadCmd) releaseCmd.AddCommand(releaseDeleteCmd) releaseListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseListCmd.Flags().Bool("draft", false, "Filter by draft status") releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status") releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch") - releaseListCmd.Flags().Bool("json", false, "Output releases as JSON") + addJSONFlags(releaseListCmd, "Output releases as JSON") releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - releaseViewCmd.Flags().Bool("json", false, "Output release as JSON") + addJSONFlags(releaseViewCmd, "Output release as JSON") + releaseViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)") @@ -88,7 +150,12 @@ func init() { releaseUploadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseUploadCmd.Flags().Bool("clobber", false, "Overwrite assets with the same name") + releaseDownloadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + releaseDownloadCmd.Flags().StringP("dir", "D", ".", "Directory to download files into") + releaseDownloadCmd.Flags().StringP("pattern", "p", "", "Glob pattern to filter assets by name") + releaseDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + releaseDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") } func runReleaseList(cmd *cobra.Command, args []string) error { @@ -131,11 +198,13 @@ func runReleaseList(cmd *cobra.Command, args []string) error { opts.IsPreRelease = &prereleaseValue } + ios.StartSpinner("Fetching releases...") var releases []*gitea.Release for page := 1; len(releases) < limit; page++ { opts.ListOptions = gitea.ListOptions{Page: page, PageSize: pageSize} batch, _, err := client.ListReleases(owner, name, opts) if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to list releases: %w", err) } if len(batch) == 0 { @@ -143,29 +212,29 @@ func runReleaseList(cmd *cobra.Command, args []string) error { } releases = append(releases, batch...) } + ios.StopSpinner() if len(releases) > limit { releases = releases[:limit] } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - return writeJSON(releases) + if wantJSON(cmd) { + return outputJSON(cmd, releases) } if len(releases) == 0 { - fmt.Printf("No releases in %s/%s\n", owner, name) + fmt.Fprintf(ios.Out, "No releases in %s/%s\n", owner, name) return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "TAG\tTITLE\tTYPE\tPUBLISHED\n") + isTTY := ios.IsStdoutTTY() + tp := ios.NewTablePrinter() + tp.AddHeader("TAG", "TITLE", "TYPE", "PUBLISHED") for _, rel := range releases { - published := releaseTimestamp(rel).Format("2006-01-02") - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.TagName, rel.Title, releaseType(rel), published) + published := text.FormatDate(releaseTimestamp(rel), isTTY) + tp.AddRow(rel.TagName, rel.Title, releaseType(rel), published) } - _ = w.Flush() - - return nil + return tp.Render() } func runReleaseView(cmd *cobra.Command, args []string) error { @@ -187,17 +256,27 @@ func runReleaseView(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching release...") release, err := getReleaseByTagOrLatest(client, owner, name, tag) if err != nil { + ios.StopSpinner() return err } attachments, err := listReleaseAttachments(client, owner, name, release.ID) + ios.StopSpinner() if err != nil { return err } - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + if web, _ := cmd.Flags().GetBool("web"); web { + if release.HTMLURL != "" { + return ios.OpenInBrowser(release.HTMLURL) + } + return fmt.Errorf("release has no HTML URL") + } + + if wantJSON(cmd) { payload := struct { Release *gitea.Release `json:"release"` Assets []*gitea.Attachment `json:"assets,omitempty"` @@ -205,33 +284,41 @@ func runReleaseView(cmd *cobra.Command, args []string) error { Release: release, Assets: attachments, } - return writeJSON(payload) + return outputJSON(cmd, payload) } - fmt.Printf("Release %s\n", release.TagName) - fmt.Printf("Title: %s\n", release.Title) - fmt.Printf("Type: %s\n", releaseType(release)) + if err := ios.StartPager(); err != nil { + fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err) + } + defer ios.StopPager() + + cs := ios.ColorScheme() + isTTY := ios.IsStdoutTTY() + + fmt.Fprintf(ios.Out, "Release %s\n", cs.Bold(release.TagName)) + fmt.Fprintf(ios.Out, "Title: %s\n", release.Title) + fmt.Fprintf(ios.Out, "Type: %s\n", releaseType(release)) if release.Target != "" { - fmt.Printf("Target: %s\n", release.Target) + fmt.Fprintf(ios.Out, "Target: %s\n", release.Target) } if release.Publisher != nil { - fmt.Printf("Author: %s\n", release.Publisher.UserName) + fmt.Fprintf(ios.Out, "Author: %s\n", release.Publisher.UserName) } - fmt.Printf("Created: %s\n", release.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(release.CreatedAt, isTTY)) if !release.PublishedAt.IsZero() { - fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(ios.Out, "Published: %s\n", text.FormatDate(release.PublishedAt, isTTY)) } if release.HTMLURL != "" { - fmt.Printf("URL: %s\n", release.HTMLURL) + fmt.Fprintf(ios.Out, "URL: %s\n", release.HTMLURL) } if release.Note != "" { - fmt.Printf("\n%s\n", release.Note) + fmt.Fprintf(ios.Out, "\n%s\n", release.Note) } if len(attachments) > 0 { - fmt.Printf("\nAssets (%d):\n", len(attachments)) + fmt.Fprintf(ios.Out, "\nAssets (%d):\n", len(attachments)) for _, asset := range attachments { - fmt.Printf("- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL) + fmt.Fprintf(ios.Out, "- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL) } } @@ -281,6 +368,7 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Creating release...") release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{ TagName: tag, Target: target, @@ -289,24 +377,29 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error { IsDraft: draft, IsPrerelease: prerelease, }) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create release: %w", err) } - fmt.Printf("Release created: %s\n", release.TagName) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Release created: %s\n", cs.SuccessIcon(), release.TagName) if release.HTMLURL != "" { - fmt.Printf("View at: %s\n", release.HTMLURL) + fmt.Fprintf(ios.Out, "View at: %s\n", release.HTMLURL) } if len(files) == 0 { return nil } + ios.StartSpinner("Uploading assets...") if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil { + ios.StopSpinner() return err } + ios.StopSpinner() - fmt.Printf("Uploaded %d asset(s)\n", len(files)) + fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset")) return nil } @@ -332,21 +425,29 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching release...") release, err := getReleaseByTagOrLatest(client, owner, name, tag) + ios.StopSpinner() if err != nil { return err } + ios.StartSpinner("Uploading assets...") if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil { + ios.StopSpinner() return err } + ios.StopSpinner() - fmt.Printf("Uploaded %d asset(s)\n", len(files)) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset")) return nil } -func runReleaseDelete(cmd *cobra.Command, args []string) error { +func runReleaseDownload(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") + dir, _ := cmd.Flags().GetString("dir") + pattern, _ := cmd.Flags().GetString("pattern") tag := args[0] owner, name, err := parseRepo(repo) @@ -364,16 +465,120 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching release...") release, err := getReleaseByTagOrLatest(client, owner, name, tag) + if err != nil { + ios.StopSpinner() + return err + } + + attachments, err := listReleaseAttachments(client, owner, name, release.ID) + ios.StopSpinner() if err != nil { return err } - if _, err := client.DeleteRelease(owner, name, release.ID); err != nil { - return fmt.Errorf("failed to delete release: %w", err) + if len(attachments) == 0 { + fmt.Fprintf(ios.Out, "No assets found for release %s\n", release.TagName) + return nil } - fmt.Printf("Release %s deleted\n", release.TagName) + // Filter by pattern if provided + var toDownload []*gitea.Attachment + for _, a := range attachments { + if pattern != "" { + matched, matchErr := path.Match(pattern, a.Name) + if matchErr != nil { + return fmt.Errorf("invalid glob pattern %q: %w", pattern, matchErr) + } + if !matched { + continue + } + } + toDownload = append(toDownload, a) + } + + if len(toDownload) == 0 { + fmt.Fprintf(ios.Out, "No assets matching pattern %q in release %s\n", pattern, release.TagName) + return nil + } + + // Ensure download directory exists + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + for _, a := range toDownload { + destPath := filepath.Join(dir, a.Name) + f, createErr := os.Create(destPath) + if createErr != nil { + return fmt.Errorf("failed to create file %s: %w", destPath, createErr) + } + + dlErr := client.DownloadFile(a.DownloadURL, f) + closeErr := f.Close() + if dlErr != nil { + return fmt.Errorf("failed to download %s: %w", a.Name, dlErr) + } + if closeErr != nil { + return fmt.Errorf("failed to close %s: %w", destPath, closeErr) + } + + fmt.Fprintf(ios.Out, "Downloaded %s (%d bytes)\n", a.Name, a.Size) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "\n%s %s downloaded to %s\n", cs.SuccessIcon(), text.Pluralize(len(toDownload), "asset"), dir) + return nil +} + +func runReleaseDelete(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + yes, _ := cmd.Flags().GetBool("yes") + tag := args[0] + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + ios.StartSpinner("Fetching release...") + release, err := getReleaseByTagOrLatest(client, owner, name, tag) + ios.StopSpinner() + if err != nil { + return err + } + + if !yes { + confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete release %s?", release.TagName)) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } + } + + ios.StartSpinner("Deleting release...") + if _, err := client.DeleteRelease(owner, name, release.ID); err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to delete release: %w", err) + } + ios.StopSpinner() + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Release %s deleted\n", cs.SuccessIcon(), release.TagName) return nil } diff --git a/cmd/repo.go b/cmd/repo.go index 7ca22cd..d216198 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -6,11 +6,11 @@ import ( "os/exec" "path/filepath" "strings" - "text/tabwriter" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) @@ -78,17 +78,34 @@ var repoEditCmd = &cobra.Command{ # Change default branch fgj repo edit --default-branch develop + # Rename a repository + fgj repo edit owner/repo --name new-name + # Edit current repo (auto-detected from git context) fgj repo edit --public`, Args: cobra.MaximumNArgs(1), RunE: runRepoEdit, } +var repoRenameCmd = &cobra.Command{ + Use: "rename ", + Short: "Rename a repository", + Long: "Rename an existing repository. This is a shorthand for `fgj repo edit --name `.", + Example: ` # Rename current repo + fgj repo rename new-name + + # Rename a specific repo + fgj repo rename new-name -R owner/old-name`, + Args: cobra.ExactArgs(1), + RunE: runRepoRename, +} + func init() { rootCmd.AddCommand(repoCmd) repoCmd.AddCommand(repoCloneCmd) repoCmd.AddCommand(repoCreateCmd) repoCmd.AddCommand(repoEditCmd) + repoCmd.AddCommand(repoRenameCmd) repoCmd.AddCommand(repoForkCmd) repoCmd.AddCommand(repoListCmd) repoCmd.AddCommand(repoViewCmd) @@ -104,16 +121,25 @@ func init() { repoCreateCmd.Flags().StringP("team", "t", "", "Team name to be granted access (org repos only)") repoCreateCmd.MarkFlagsMutuallyExclusive("public", "private") + addJSONFlags(repoViewCmd, "Output repository as JSON") + repoViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") + + addJSONFlags(repoListCmd, "Output repositories as JSON") + repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh") repoEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + repoEditCmd.Flags().String("name", "", "Rename the repository") repoEditCmd.Flags().StringP("description", "d", "", "Repository description") repoEditCmd.Flags().String("homepage", "", "Repository home page URL") repoEditCmd.Flags().String("default-branch", "", "Default branch name") repoEditCmd.Flags().Bool("private", false, "Make the repository private") repoEditCmd.Flags().Bool("public", false, "Make the repository public") - repoEditCmd.Flags().Bool("json", false, "Output updated repository as JSON") + addJSONFlags(repoEditCmd, "Output updated repository as JSON") repoEditCmd.MarkFlagsMutuallyExclusive("public", "private") + + repoRenameCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + addJSONFlags(repoRenameCmd, "Output updated repository as JSON") } func runRepoView(cmd *cobra.Command, args []string) error { @@ -137,23 +163,36 @@ func runRepoView(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching repository...") repository, _, err := client.GetRepo(owner, name) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to get repository: %w", err) } - fmt.Printf("Repository: %s/%s\n", repository.Owner.UserName, repository.Name) - fmt.Printf("Description: %s\n", repository.Description) - fmt.Printf("URL: %s\n", repository.HTMLURL) - fmt.Printf("Clone URL (HTTPS): %s\n", repository.CloneURL) - fmt.Printf("Clone URL (SSH): %s\n", repository.SSHURL) - fmt.Printf("Default Branch: %s\n", repository.DefaultBranch) - fmt.Printf("Stars: %d\n", repository.Stars) - fmt.Printf("Forks: %d\n", repository.Forks) - fmt.Printf("Open Issues: %d\n", repository.OpenIssues) - fmt.Printf("Private: %v\n", repository.Private) - fmt.Printf("Created: %s\n", repository.Created.Format("2006-01-02 15:04:05")) - fmt.Printf("Updated: %s\n", repository.Updated.Format("2006-01-02 15:04:05")) + if web, _ := cmd.Flags().GetBool("web"); web { + return ios.OpenInBrowser(repository.HTMLURL) + } + + if wantJSON(cmd) { + return outputJSON(cmd, repository) + } + + cs := ios.ColorScheme() + isTTY := ios.IsStdoutTTY() + + fmt.Fprintf(ios.Out, "Repository: %s\n", cs.Bold(fmt.Sprintf("%s/%s", repository.Owner.UserName, repository.Name))) + fmt.Fprintf(ios.Out, "Description: %s\n", repository.Description) + fmt.Fprintf(ios.Out, "URL: %s\n", repository.HTMLURL) + fmt.Fprintf(ios.Out, "Clone URL (HTTPS): %s\n", repository.CloneURL) + fmt.Fprintf(ios.Out, "Clone URL (SSH): %s\n", repository.SSHURL) + fmt.Fprintf(ios.Out, "Default Branch: %s\n", repository.DefaultBranch) + fmt.Fprintf(ios.Out, "Stars: %d\n", repository.Stars) + fmt.Fprintf(ios.Out, "Forks: %d\n", repository.Forks) + fmt.Fprintf(ios.Out, "Open Issues: %d\n", repository.OpenIssues) + fmt.Fprintf(ios.Out, "Private: %v\n", repository.Private) + fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(repository.Created, isTTY)) + fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(repository.Updated, isTTY)) return nil } @@ -169,37 +208,39 @@ func runRepoList(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching repositories...") user, _, err := client.GetMyUserInfo() if err != nil { + ios.StopSpinner() return fmt.Errorf("failed to get user info: %w", err) } repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{}) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to list repositories: %w", err) } + if wantJSON(cmd) { + return outputJSON(cmd, repos) + } + if len(repos) == 0 { - fmt.Println("No repositories found") + fmt.Fprintln(ios.Out, "No repositories found") return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n") + tp := ios.NewTablePrinter() + tp.AddHeader("NAME", "VISIBILITY", "DESCRIPTION") for _, repo := range repos { visibility := "public" if repo.Private { visibility = "private" } - desc := repo.Description - if len(desc) > 50 { - desc = desc[:47] + "..." - } - _, _ = fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc) + desc := text.Truncate(repo.Description, 50) + tp.AddRow(fmt.Sprintf("%s/%s", repo.Owner.UserName, repo.Name), visibility, desc) } - _ = w.Flush() - - return nil + return tp.Render() } func runRepoClone(cmd *cobra.Command, args []string) error { @@ -221,7 +262,9 @@ func runRepoClone(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Fetching repository info...") repository, _, err := client.GetRepo(owner, name) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to get repository: %w", err) } @@ -241,7 +284,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error { destination = name } - fmt.Printf("Cloning %s/%s to %s...\n", owner, name, destination) + fmt.Fprintf(ios.Out, "Cloning %s/%s to %s...\n", owner, name, destination) // Create parent directory if it doesn't exist if dir := filepath.Dir(destination); dir != "." { @@ -250,17 +293,21 @@ func runRepoClone(cmd *cobra.Command, args []string) error { } } + ios.StartSpinner("Cloning repository...") // Execute git clone gitCmd := exec.Command("git", "clone", cloneURL, destination) - gitCmd.Stdout = os.Stdout - gitCmd.Stderr = os.Stderr - gitCmd.Stdin = os.Stdin + gitCmd.Stdout = ios.Out + gitCmd.Stderr = ios.ErrOut + gitCmd.Stdin = ios.In if err := gitCmd.Run(); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to clone repository: %w", err) } + ios.StopSpinner() - fmt.Printf("Repository cloned successfully to %s\n", destination) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Repository cloned successfully to %s\n", cs.SuccessIcon(), destination) return nil } @@ -282,14 +329,17 @@ func runRepoFork(cmd *cobra.Command, args []string) error { return err } + ios.StartSpinner("Forking repository...") fork, _, err := client.CreateFork(owner, name, gitea.CreateForkOption{}) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to fork repository: %w", err) } - fmt.Printf("Repository forked successfully\n") - fmt.Printf("View at: %s\n", fork.HTMLURL) - fmt.Printf("Clone URL: %s\n", fork.CloneURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Repository forked successfully\n", cs.SuccessIcon()) + fmt.Fprintf(ios.Out, "View at: %s\n", fork.HTMLURL) + fmt.Fprintf(ios.Out, "Clone URL: %s\n", fork.CloneURL) return nil } @@ -335,12 +385,14 @@ func runRepoCreate(cmd *cobra.Command, args []string) error { License: license, } + ios.StartSpinner("Creating repository...") var repo *gitea.Repository if isOrg { repo, _, err = client.CreateOrgRepo(org, opt) } else { repo, _, err = client.CreateRepo(opt) } + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create repository: %w", err) } @@ -354,7 +406,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error { } else { user, _, userErr := client.GetMyUserInfo() if userErr != nil { - fmt.Fprintf(os.Stderr, "warning: repository created but could not determine owner for homepage: %v\n", userErr) + fmt.Fprintf(ios.ErrOut, "warning: repository created but could not determine owner for homepage: %v\n", userErr) homepage = "" // skip EditRepo } else { ownerName = user.UserName @@ -366,23 +418,24 @@ func runRepoCreate(cmd *cobra.Command, args []string) error { Website: &homepage, }) if err != nil { - fmt.Fprintf(os.Stderr, "warning: repository created but failed to set homepage: %v\n", err) + fmt.Fprintf(ios.ErrOut, "warning: repository created but failed to set homepage: %v\n", err) } } } if team != "" { if !isOrg { - fmt.Fprintln(os.Stderr, "warning: --team is only meaningful for organization repositories") + fmt.Fprintln(ios.ErrOut, "warning: --team is only meaningful for organization repositories") } else { _, err = client.AddRepoTeam(org, repo.Name, team) if err != nil { - fmt.Fprintf(os.Stderr, "warning: repository created but failed to add team %q: %v\n", team, err) + fmt.Fprintf(ios.ErrOut, "warning: repository created but failed to add team %q: %v\n", team, err) } } } - fmt.Printf("Repository created: %s\n", repo.HTMLURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Repository created: %s\n", cs.SuccessIcon(), repo.HTMLURL) if doClone { cloneURL := repo.CloneURL @@ -391,11 +444,11 @@ func runRepoCreate(cmd *cobra.Command, args []string) error { cloneURL = repo.SSHURL } } - fmt.Printf("Cloning into %s...\n", repo.Name) + fmt.Fprintf(ios.Out, "Cloning into %s...\n", repo.Name) gitCmd := exec.Command("git", "clone", cloneURL, repo.Name) - gitCmd.Stdout = os.Stdout - gitCmd.Stderr = os.Stderr - gitCmd.Stdin = os.Stdin + gitCmd.Stdout = ios.Out + gitCmd.Stderr = ios.ErrOut + gitCmd.Stdin = ios.In if err := gitCmd.Run(); err != nil { return fmt.Errorf("failed to clone repository: %w", err) } @@ -449,6 +502,11 @@ func runRepoEdit(cmd *cobra.Command, args []string) error { opt := gitea.EditRepoOption{} changed := false + if cmd.Flags().Changed("name") { + n, _ := cmd.Flags().GetString("name") + opt.Name = &n + changed = true + } if cmd.Flags().Changed("description") { d, _ := cmd.Flags().GetString("description") opt.Description = &d @@ -476,36 +534,84 @@ func runRepoEdit(cmd *cobra.Command, args []string) error { } if !changed { - return fmt.Errorf("no changes specified; use flags like --public, --private, --description, --homepage, or --default-branch") + return fmt.Errorf("no changes specified; use flags like --name, --public, --private, --description, --homepage, or --default-branch") } + ios.StartSpinner("Updating repository...") repository, _, err := client.EditRepo(owner, name, opt) + ios.StopSpinner() if err != nil { return fmt.Errorf("failed to edit repository: %w", err) } - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(repository) + if wantJSON(cmd) { + return outputJSON(cmd, repository) } - fmt.Printf("Repository updated: %s\n", repository.HTMLURL) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Repository updated: %s\n", cs.SuccessIcon(), repository.HTMLURL) + if opt.Name != nil { + fmt.Fprintf(ios.Out, "Renamed to: %s\n", repository.FullName) + } if opt.Private != nil { if *opt.Private { - fmt.Println("Visibility: private") + fmt.Fprintln(ios.Out, "Visibility: private") } else { - fmt.Println("Visibility: public") + fmt.Fprintln(ios.Out, "Visibility: public") } } if opt.Description != nil { - fmt.Printf("Description: %s\n", *opt.Description) + fmt.Fprintf(ios.Out, "Description: %s\n", *opt.Description) } if opt.Website != nil { - fmt.Printf("Homepage: %s\n", *opt.Website) + fmt.Fprintf(ios.Out, "Homepage: %s\n", *opt.Website) } if opt.DefaultBranch != nil { - fmt.Printf("Default branch: %s\n", *opt.DefaultBranch) + fmt.Fprintf(ios.Out, "Default branch: %s\n", *opt.DefaultBranch) } return nil } + +func runRepoRename(cmd *cobra.Command, args []string) error { + var repo string + if r, _ := cmd.Flags().GetString("repo"); r != "" { + repo = r + } + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + newName := args[0] + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + if err != nil { + return err + } + + opt := gitea.EditRepoOption{ + Name: &newName, + } + + ios.StartSpinner("Renaming repository...") + repository, _, err := client.EditRepo(owner, name, opt) + ios.StopSpinner() + if err != nil { + return fmt.Errorf("failed to rename repository: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, repository) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Renamed %s/%s to %s\n", cs.SuccessIcon(), owner, name, repository.FullName) + return nil +} diff --git a/cmd/root.go b/cmd/root.go index dab7fb4..edb1c39 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "strconv" "strings" "forgejo.zerova.net/sid/fgj-sid/internal/git" @@ -46,7 +47,7 @@ func initConfig() { } else { home, err := os.UserHomeDir() if err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(ios.ErrOut, err) os.Exit(1) } @@ -94,3 +95,26 @@ func getDetectedHost() string { } return host } + +// promptLine prints a prompt to stderr and reads a line from stdin. +func promptLine(prompt string) (string, error) { + fmt.Fprint(ios.ErrOut, prompt) + var buf [1024]byte + n, err := ios.In.Read(buf[:]) + if err != nil { + return "", fmt.Errorf("reading input: %w", err) + } + return strings.TrimSpace(string(buf[:n])), nil +} + +// parseIssueArg parses an issue/PR number from various formats: +// "123", "#123", "https://host/owner/repo/pulls/123", "https://host/owner/repo/issues/123" +func parseIssueArg(arg string) (int64, error) { + arg = strings.TrimPrefix(arg, "#") + // Try URL format + if strings.HasPrefix(arg, "http") { + parts := strings.Split(strings.TrimRight(arg, "/"), "/") + arg = parts[len(parts)-1] + } + return strconv.ParseInt(arg, 10, 64) +} diff --git a/cmd/wiki.go b/cmd/wiki.go index 1e5786a..4c2d65d 100644 --- a/cmd/wiki.go +++ b/cmd/wiki.go @@ -5,29 +5,28 @@ import ( "fmt" "net/http" "net/url" - "os" - "text/tabwriter" "time" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/sid/fgj-sid/internal/text" "github.com/spf13/cobra" ) // Wiki API response types type wikiPageMeta struct { - Title string `json:"title"` - HTMLURL string `json:"html_url"` - SubURL string `json:"sub_url"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + SubURL string `json:"sub_url"` LastCommit *wikiCommit `json:"last_commit"` } type wikiCommit struct { - ID string `json:"id"` - Author *wikiUser `json:"author"` - Committer *wikiUser `json:"committer"` - Message string `json:"message"` + ID string `json:"id"` + Author *wikiUser `json:"author"` + Committer *wikiUser `json:"committer"` + Message string `json:"message"` } type wikiUser struct { @@ -79,6 +78,9 @@ var wikiViewCmd = &cobra.Command{ Example: ` # View a wiki page fgj wiki view Home + # Open in browser + fgj wiki view Home --web + # View a wiki page as JSON (includes content) fgj wiki view Home --json @@ -133,6 +135,9 @@ var wikiDeleteCmd = &cobra.Command{ Example: ` # Delete a wiki page fgj wiki delete "Old Page" + # Delete without confirmation + fgj wiki delete "Old Page" -y + # Delete a wiki page from a specific repo fgj wiki delete "Outdated Guide" -R owner/repo`, Args: cobra.ExactArgs(1), @@ -148,22 +153,24 @@ func init() { wikiCmd.AddCommand(wikiDeleteCmd) wikiListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - wikiListCmd.Flags().Bool("json", false, "Output as JSON") + addJSONFlags(wikiListCmd, "Output as JSON") wikiViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") - wikiViewCmd.Flags().Bool("json", false, "Output as JSON") + addJSONFlags(wikiViewCmd, "Output as JSON") + wikiViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") wikiCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") wikiCreateCmd.Flags().StringP("body", "b", "", "Wiki page content") wikiCreateCmd.Flags().String("body-file", "", "Read content from file (use \"-\" for stdin)") - wikiCreateCmd.Flags().Bool("json", false, "Output created page as JSON") + addJSONFlags(wikiCreateCmd, "Output created page as JSON") wikiEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") wikiEditCmd.Flags().StringP("body", "b", "", "Wiki page content") wikiEditCmd.Flags().String("body-file", "", "Read content from file (use \"-\" for stdin)") - wikiEditCmd.Flags().Bool("json", false, "Output updated page as JSON") + addJSONFlags(wikiEditCmd, "Output updated page as JSON") wikiDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + wikiDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") } func newWikiClient(cmd *cobra.Command) (*api.Client, string, string, error) { @@ -194,37 +201,38 @@ func runWikiList(cmd *cobra.Command, args []string) error { path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/pages", url.PathEscape(owner), url.PathEscape(name)) + ios.StartSpinner("Fetching wiki pages...") var pages []wikiPageMeta if err := client.GetJSON(path, &pages); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to list wiki pages: %w", err) } + ios.StopSpinner() - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(pages) + if wantJSON(cmd) { + return outputJSON(cmd, pages) } if len(pages) == 0 { - fmt.Println("No wiki pages found") + fmt.Fprintln(ios.Out, "No wiki pages found") return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintf(w, "TITLE\tLAST UPDATED\n") + isTTY := ios.IsStdoutTTY() + tp := ios.NewTablePrinter() + tp.AddHeader("TITLE", "LAST UPDATED") for _, p := range pages { updated := "" if p.LastCommit != nil && p.LastCommit.Committer != nil && p.LastCommit.Committer.Date != "" { if t, err := time.Parse(time.RFC3339, p.LastCommit.Committer.Date); err == nil { - updated = t.Format("2006-01-02 15:04:05") + updated = text.FormatDate(t, isTTY) } else { updated = p.LastCommit.Committer.Date } } - _, _ = fmt.Fprintf(w, "%s\t%s\n", p.Title, updated) + tp.AddRow(p.Title, updated) } - _ = w.Flush() - - return nil + return tp.Render() } func runWikiView(cmd *cobra.Command, args []string) error { @@ -238,27 +246,42 @@ func runWikiView(cmd *cobra.Command, args []string) error { path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title)) + ios.StartSpinner("Fetching wiki page...") var page wikiPage if err := client.GetJSON(path, &page); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to get wiki page: %w", err) } + ios.StopSpinner() content, err := base64.StdEncoding.DecodeString(page.ContentBase64) if err != nil { return fmt.Errorf("failed to decode wiki page content: %w", err) } + if web, _ := cmd.Flags().GetBool("web"); web { + if page.HTMLURL != "" { + return ios.OpenInBrowser(page.HTMLURL) + } + return fmt.Errorf("wiki page has no HTML URL") + } + jsonFlag, _ := cmd.Flags().GetBool("json") if jsonFlag { page.Content = string(content) return writeJSON(page) } - fmt.Printf("# %s\n\n", page.Title) - fmt.Print(string(content)) + if err := ios.StartPager(); err != nil { + fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err) + } + defer ios.StopPager() + + fmt.Fprintf(ios.Out, "# %s\n\n", page.Title) + fmt.Fprint(ios.Out, string(content)) // Ensure trailing newline if len(content) > 0 && content[len(content)-1] != '\n' { - fmt.Println() + fmt.Fprintln(ios.Out) } return nil @@ -288,17 +311,20 @@ func runWikiCreate(cmd *cobra.Command, args []string) error { ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)), } + ios.StartSpinner("Creating wiki page...") var page wikiPage if _, err := client.DoJSON(http.MethodPost, path, reqBody, &page); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to create wiki page: %w", err) } + ios.StopSpinner() - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(page) + if wantJSON(cmd) { + return outputJSON(cmd, page) } - fmt.Printf("Wiki page created: %s\n", title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Wiki page created: %s\n", cs.SuccessIcon(), title) return nil } @@ -326,35 +352,54 @@ func runWikiEdit(cmd *cobra.Command, args []string) error { ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)), } + ios.StartSpinner("Updating wiki page...") var page wikiPage if _, err := client.DoJSON(http.MethodPatch, path, reqBody, &page); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to update wiki page: %w", err) } + ios.StopSpinner() - jsonFlag, _ := cmd.Flags().GetBool("json") - if jsonFlag { - return writeJSON(page) + if wantJSON(cmd) { + return outputJSON(cmd, page) } - fmt.Printf("Wiki page updated: %s\n", title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Wiki page updated: %s\n", cs.SuccessIcon(), title) return nil } func runWikiDelete(cmd *cobra.Command, args []string) error { title := args[0] + yes, _ := cmd.Flags().GetBool("yes") client, owner, name, err := newWikiClient(cmd) if err != nil { return err } + if !yes { + confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete wiki page %q?", title)) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintln(ios.ErrOut, "Aborted") + return nil + } + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title)) + ios.StartSpinner("Deleting wiki page...") if _, err := client.DoJSON(http.MethodDelete, path, nil, nil); err != nil { + ios.StopSpinner() return fmt.Errorf("failed to delete wiki page: %w", err) } + ios.StopSpinner() - fmt.Printf("Wiki page deleted: %s\n", title) + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Wiki page deleted: %s\n", cs.SuccessIcon(), title) return nil } diff --git a/go.mod b/go.mod index 0025ca9..b13d8ce 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module forgejo.zerova.net/sid/fgj-sid -go 1.23.0 +go 1.24.0 require ( code.gitea.io/sdk/gitea v0.22.1 @@ -19,6 +19,8 @@ require ( github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/gojq v0.12.18 // indirect + github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect @@ -34,7 +36,7 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/crypto v0.39.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.26.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index f344c22..7618c0c 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,10 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= +github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= +github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= +github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -88,6 +92,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= diff --git a/internal/api/client.go b/internal/api/client.go index b69f3f7..d5869d3 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -6,11 +6,16 @@ import ( "fmt" "io" "net/http" + "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/config" ) +var sharedHTTPClient = &http.Client{ + Timeout: 30 * time.Second, +} + type Client struct { *gitea.Client hostname string @@ -63,8 +68,7 @@ func (c *Client) GetJSON(path string, result any) error { } req.Header.Set("Accept", "application/json") - httpClient := &http.Client{} - resp, err := httpClient.Do(req) + resp, err := sharedHTTPClient.Do(req) if err != nil { return fmt.Errorf("failed to perform request: %w", err) } @@ -74,8 +78,11 @@ func (c *Client) GetJSON(path string, result any) error { } }() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("failed to read error response body: %w", readErr) + } return &APIError{ StatusCode: resp.StatusCode, Body: string(body), @@ -125,8 +132,7 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int, req.Header.Set("Content-Type", "application/json") } - httpClient := &http.Client{} - resp, err := httpClient.Do(req) + resp, err := sharedHTTPClient.Do(req) if err != nil { return 0, fmt.Errorf("failed to perform request: %w", err) } @@ -136,8 +142,11 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int, } }() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { - bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return resp.StatusCode, fmt.Errorf("failed to read error response body: %w", readErr) + } return resp.StatusCode, &APIError{ StatusCode: resp.StatusCode, Body: string(bodyBytes), @@ -154,6 +163,40 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int, return resp.StatusCode, nil } +// Token returns the client's authentication token. +func (c *Client) Token() string { + return c.token +} + +// DownloadFile performs an authenticated GET request and writes the response body to the given writer. +func (c *Client) DownloadFile(url string, w io.Writer) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + + resp, err := sharedHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to perform request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(body)) + } + + if _, err := io.Copy(w, resp.Body); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + // GetRawLog performs a GET request and returns the raw response body as string func (c *Client) GetRawLog(url string) (string, error) { req, err := http.NewRequest(http.MethodGet, url, nil) @@ -166,8 +209,7 @@ func (c *Client) GetRawLog(url string) (string, error) { req.Header.Set("Authorization", "token "+c.token) } - httpClient := &http.Client{} - resp, err := httpClient.Do(req) + resp, err := sharedHTTPClient.Do(req) if err != nil { return "", fmt.Errorf("failed to perform request: %w", err) } @@ -178,7 +220,10 @@ func (c *Client) GetRawLog(url string) (string, error) { }() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return "", fmt.Errorf("failed to read error response body: %w", readErr) + } return "", &APIError{ StatusCode: resp.StatusCode, Body: string(body), diff --git a/internal/git/git.go b/internal/git/git.go index 425c764..0491953 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -114,6 +114,48 @@ func parseGitConfig(configPath string) (string, error) { return "", fmt.Errorf("no origin remote found in git config") } +// GetCurrentBranch returns the name of the currently checked-out branch. +func GetCurrentBranch() (string, error) { + gitDir, err := findGitDir() + if err != nil { + return "", err + } + + headPath := filepath.Join(gitDir, "HEAD") + data, err := os.ReadFile(headPath) + if err != nil { + return "", fmt.Errorf("failed to read .git/HEAD: %w", err) + } + + headStr := strings.TrimSpace(string(data)) + if strings.HasPrefix(headStr, "ref: refs/heads/") { + return strings.TrimPrefix(headStr, "ref: refs/heads/"), nil + } + + return "", fmt.Errorf("HEAD is not on a branch (detached HEAD)") +} + +// findGitDir searches for the .git directory starting from the current directory +func findGitDir() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + + dir := cwd + for { + gitDir := filepath.Join(dir, ".git") + if info, err := os.Stat(gitDir); err == nil && info.IsDir() { + return gitDir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("not in a git repository") + } + dir = parent + } +} + // parseRemoteURL extracts owner/name/hostname from various git URL formats: // - https://codeberg.org/owner/name.git // - git@codeberg.org:owner/name.git diff --git a/internal/iostreams/color.go b/internal/iostreams/color.go new file mode 100644 index 0000000..43a5475 --- /dev/null +++ b/internal/iostreams/color.go @@ -0,0 +1,77 @@ +package iostreams + +import "fmt" + +// ColorScheme provides semantic color methods that respect whether color output is enabled. +type ColorScheme struct { + enabled bool +} + +// NewColorScheme creates a ColorScheme. When enabled is false, all methods return +// undecorated text. +func NewColorScheme(enabled bool) *ColorScheme { + return &ColorScheme{enabled: enabled} +} + +// colorize wraps text in ANSI escape codes if color is enabled. +func (cs *ColorScheme) colorize(code string, text string) string { + if !cs.enabled { + return text + } + return fmt.Sprintf("\033[%sm%s\033[0m", code, text) +} + +// Bold renders text in bold. +func (cs *ColorScheme) Bold(s string) string { + return cs.colorize("1", s) +} + +// Red renders text in red. +func (cs *ColorScheme) Red(s string) string { + return cs.colorize("31", s) +} + +// Green renders text in green. +func (cs *ColorScheme) Green(s string) string { + return cs.colorize("32", s) +} + +// Yellow renders text in yellow. +func (cs *ColorScheme) Yellow(s string) string { + return cs.colorize("33", s) +} + +// Cyan renders text in cyan. +func (cs *ColorScheme) Cyan(s string) string { + return cs.colorize("36", s) +} + +// Magenta renders text in magenta. +func (cs *ColorScheme) Magenta(s string) string { + return cs.colorize("35", s) +} + +// Muted renders text in gray (dimmed). +func (cs *ColorScheme) Muted(s string) string { + return cs.colorize("90", s) +} + +// SuccessIcon returns a green check mark if color is enabled, plain otherwise. +func (cs *ColorScheme) SuccessIcon() string { + return cs.Green("✓") +} + +// WarningIcon returns a yellow exclamation mark if color is enabled, plain otherwise. +func (cs *ColorScheme) WarningIcon() string { + return cs.Yellow("!") +} + +// FailureIcon returns a red X mark if color is enabled, plain otherwise. +func (cs *ColorScheme) FailureIcon() string { + return cs.Red("✗") +} + +// SuccessIconWithColor returns the success icon followed by the message in green. +func (cs *ColorScheme) SuccessIconWithColor(msg string) string { + return cs.SuccessIcon() + " " + cs.Green(msg) +} diff --git a/internal/iostreams/iostreams.go b/internal/iostreams/iostreams.go new file mode 100644 index 0000000..88561ad --- /dev/null +++ b/internal/iostreams/iostreams.go @@ -0,0 +1,272 @@ +package iostreams + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "time" + + "golang.org/x/term" +) + +// IOStreams provides the standard streams for the CLI along with TTY detection, +// color support, pager integration, and other terminal helpers. +type IOStreams struct { + In io.Reader + Out io.Writer + ErrOut io.Writer + + // Private fields for state + isStdinTTY bool + isStdoutTTY bool + isStderrTTY bool + + pagerProcess *exec.Cmd + pagerPipe io.WriteCloser + originalOut io.Writer + + colorScheme *ColorScheme + + spinnerMu sync.Mutex + spinnerCancel chan struct{} +} + +// New creates an IOStreams wired to the real os.Stdin, os.Stdout, and os.Stderr, +// with TTY status auto-detected. Setting FGJ_FORCE_TTY=1 forces all streams to +// be treated as TTYs. +func New() *IOStreams { + forceTTY := os.Getenv("FGJ_FORCE_TTY") != "" + + stdinTTY := forceTTY || (isTerminal(os.Stdin.Fd())) + stdoutTTY := forceTTY || (isTerminal(os.Stdout.Fd())) + stderrTTY := forceTTY || (isTerminal(os.Stderr.Fd())) + + return &IOStreams{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + isStdinTTY: stdinTTY, + isStdoutTTY: stdoutTTY, + isStderrTTY: stderrTTY, + } +} + +// Test creates an IOStreams backed by bytes.Buffers, suitable for unit tests. +// All TTY flags are false. +func Test() *IOStreams { + return &IOStreams{ + In: &bytes.Buffer{}, + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + isStdinTTY: false, + isStdoutTTY: false, + isStderrTTY: false, + } +} + +// IsStdinTTY reports whether standard input is connected to a terminal. +func (s *IOStreams) IsStdinTTY() bool { + return s.isStdinTTY +} + +// IsStdoutTTY reports whether standard output is connected to a terminal. +func (s *IOStreams) IsStdoutTTY() bool { + return s.isStdoutTTY +} + +// IsStderrTTY reports whether standard error is connected to a terminal. +func (s *IOStreams) IsStderrTTY() bool { + return s.isStderrTTY +} + +// TerminalWidth returns the width of the terminal connected to stdout. If stdout +// is not a terminal, it returns 80. +func (s *IOStreams) TerminalWidth() int { + if !s.isStdoutTTY { + return 80 + } + if f, ok := s.Out.(*os.File); ok { + w, _, err := term.GetSize(int(f.Fd())) + if err == nil && w > 0 { + return w + } + } + return 80 +} + +// ColorEnabled returns true when color output should be used. Color is enabled +// when stdout is a TTY and the NO_COLOR environment variable is not set. +func (s *IOStreams) ColorEnabled() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + return s.isStdoutTTY +} + +// ColorScheme returns a lazily-initialized ColorScheme that respects the current +// color settings. +func (s *IOStreams) ColorScheme() *ColorScheme { + if s.colorScheme == nil { + s.colorScheme = NewColorScheme(s.ColorEnabled()) + } + return s.colorScheme +} + +// StartPager starts an external pager process and redirects Out to its stdin. +// It checks FGJ_PAGER, then PAGER, then defaults to "less". If LESS is not +// already set, it is set to "FRX" for a good default experience. +func (s *IOStreams) StartPager() error { + if !s.isStdoutTTY { + return nil + } + + pagerCmd := os.Getenv("FGJ_PAGER") + if pagerCmd == "" { + pagerCmd = os.Getenv("PAGER") + } + if pagerCmd == "" { + pagerCmd = "less" + } + + if os.Getenv("LESS") == "" { + os.Setenv("LESS", "FRX") + } + + parts := strings.Fields(pagerCmd) + //nolint:gosec // pager command is user-configured + cmd := exec.Command(parts[0], parts[1:]...) + cmd.Stdout = s.Out + cmd.Stderr = s.ErrOut + + pipe, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("creating pager pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("starting pager: %w", err) + } + + s.pagerProcess = cmd + s.pagerPipe = pipe + s.originalOut = s.Out + s.Out = pipe + + return nil +} + +// StopPager closes the pager's stdin pipe and waits for the process to exit. +// It restores Out to the original writer. +func (s *IOStreams) StopPager() { + if s.pagerPipe == nil { + return + } + + _ = s.pagerPipe.Close() + _ = s.pagerProcess.Wait() + + s.Out = s.originalOut + s.pagerPipe = nil + s.pagerProcess = nil + s.originalOut = nil +} + +// spinnerFrames are the Braille-based animation frames for the spinner. +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// StartSpinner shows an animated spinner with the given label on stderr. It only +// runs when stderr is a TTY. Call StopSpinner to halt it. +func (s *IOStreams) StartSpinner(label string) { + if !s.isStderrTTY { + return + } + + s.spinnerMu.Lock() + defer s.spinnerMu.Unlock() + + // Stop any existing spinner first. + if s.spinnerCancel != nil { + close(s.spinnerCancel) + s.spinnerCancel = nil + } + + cancel := make(chan struct{}) + s.spinnerCancel = cancel + + go func() { + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + i := 0 + for { + select { + case <-cancel: + // Clear the spinner line. + fmt.Fprintf(s.ErrOut, "\r\033[K") + return + case <-ticker.C: + frame := spinnerFrames[i%len(spinnerFrames)] + fmt.Fprintf(s.ErrOut, "\r%s %s", frame, label) + i++ + } + } + }() +} + +// StopSpinner halts the spinner and clears the line on stderr. +func (s *IOStreams) StopSpinner() { + s.spinnerMu.Lock() + defer s.spinnerMu.Unlock() + + if s.spinnerCancel != nil { + close(s.spinnerCancel) + s.spinnerCancel = nil + } +} + +// OpenInBrowser opens the given URL in the user's default browser. +func (s *IOStreams) OpenInBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: // linux, freebsd, etc. + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} + +// ConfirmAction prompts the user with a yes/no question and returns their +// answer. It returns an error if stdin is not a TTY (non-interactive). +func (s *IOStreams) ConfirmAction(prompt string) (bool, error) { + if !s.isStdinTTY { + return false, fmt.Errorf("cannot prompt for confirmation: not an interactive terminal") + } + + fmt.Fprintf(s.ErrOut, "%s [y/N]: ", prompt) + + var response string + if _, err := fmt.Fscan(s.In, &response); err != nil { + return false, fmt.Errorf("reading response: %w", err) + } + + response = strings.ToLower(strings.TrimSpace(response)) + return response == "y" || response == "yes", nil +} + +// NewTablePrinter creates a TablePrinter that writes to this IOStreams' output. +func (s *IOStreams) NewTablePrinter() *TablePrinter { + return NewTablePrinter(s) +} + +// isTerminal reports whether the given file descriptor is a terminal. +func isTerminal(fd uintptr) bool { + return term.IsTerminal(int(fd)) +} diff --git a/internal/iostreams/table.go b/internal/iostreams/table.go new file mode 100644 index 0000000..a97f711 --- /dev/null +++ b/internal/iostreams/table.go @@ -0,0 +1,64 @@ +package iostreams + +import ( + "fmt" + "strings" + "text/tabwriter" +) + +// TablePrinter prints TTY-aware tables. In TTY mode it uses aligned columns with +// bold headers. In pipe mode it emits tab-separated values without headers. +type TablePrinter struct { + ios *IOStreams + headers []string + rows [][]string +} + +// NewTablePrinter creates a TablePrinter that writes to ios.Out. +func NewTablePrinter(ios *IOStreams) *TablePrinter { + return &TablePrinter{ + ios: ios, + } +} + +// AddHeader sets the column headers. Headers are only displayed in TTY mode. +func (t *TablePrinter) AddHeader(headers ...string) { + t.headers = headers +} + +// AddRow appends a row of fields to the table. +func (t *TablePrinter) AddRow(fields ...string) { + t.rows = append(t.rows, fields) +} + +// Render writes the table to the IOStreams output. In TTY mode it uses tabwriter +// with bold headers. In pipe mode it emits tab-separated values without headers. +func (t *TablePrinter) Render() error { + if !t.ios.IsStdoutTTY() { + // Pipe mode: tab-separated, no headers + for _, row := range t.rows { + if _, err := fmt.Fprintln(t.ios.Out, strings.Join(row, "\t")); err != nil { + return err + } + } + return nil + } + + // TTY mode: use tabwriter with aligned columns + w := tabwriter.NewWriter(t.ios.Out, 0, 0, 2, ' ', 0) + + if len(t.headers) > 0 { + cs := t.ios.ColorScheme() + boldHeaders := make([]string, len(t.headers)) + for i, h := range t.headers { + boldHeaders[i] = cs.Bold(h) + } + fmt.Fprintln(w, strings.Join(boldHeaders, "\t")) + } + + for _, row := range t.rows { + fmt.Fprintln(w, strings.Join(row, "\t")) + } + + return w.Flush() +} diff --git a/internal/text/text.go b/internal/text/text.go new file mode 100644 index 0000000..06e631c --- /dev/null +++ b/internal/text/text.go @@ -0,0 +1,70 @@ +package text + +import ( + "fmt" + "math" + "time" +) + +// Pluralize returns "1 issue" or "2 issues" depending on count. +// It applies a simple "s" suffix rule. +func Pluralize(count int, singular string) string { + if count == 1 { + return fmt.Sprintf("%d %s", count, singular) + } + return fmt.Sprintf("%d %ss", count, singular) +} + +// FuzzyAgo returns a human-friendly relative time string like "just now", +// "2 minutes ago", "3 hours ago", etc. +func FuzzyAgo(t time.Time) string { + d := time.Since(t) + + if d < time.Minute { + return "just now" + } + + minutes := int(math.Floor(d.Minutes())) + if minutes < 60 { + return fmt.Sprintf("%s ago", Pluralize(minutes, "minute")) + } + + hours := int(math.Floor(d.Hours())) + if hours < 24 { + return fmt.Sprintf("%s ago", Pluralize(hours, "hour")) + } + + days := hours / 24 + if days < 30 { + return fmt.Sprintf("%s ago", Pluralize(days, "day")) + } + + months := days / 30 + if months < 12 { + return fmt.Sprintf("%s ago", Pluralize(months, "month")) + } + + years := months / 12 + return fmt.Sprintf("%s ago", Pluralize(years, "year")) +} + +// Truncate shortens text to maxWidth, replacing the end with "..." if it exceeds +// the limit. If maxWidth is less than or equal to 3, the result is just "...". +func Truncate(text string, maxWidth int) string { + if len(text) <= maxWidth { + return text + } + if maxWidth <= 3 { + return "..."[:maxWidth] + } + return text[:maxWidth-3] + "..." +} + +// FormatDate returns a human-friendly relative time for TTY output, or an +// RFC3339 timestamp for piped output. +func FormatDate(t time.Time, isTTY bool) string { + if isTTY { + return FuzzyAgo(t) + } + return t.Format(time.RFC3339) +} diff --git a/main.go b/main.go index 76dd977..b3b29a0 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( func main() { if err := cmd.Execute(); err != nil { + err = cmd.ContextualError(err) if cmd.JSONErrors() { cmd.WriteJSONError(err) } else { From c293e233d2609f5433d5d52de99df827dd571802 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 23 Mar 2026 12:39:51 -0600 Subject: [PATCH 03/20] feat: add directory-scoped host defaults (match_dirs) and repo list --limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add match_dirs field to host config entries for directory-based host resolution. When no --hostname flag, FGJ_HOST env var, or git remote is detected, the longest matching directory prefix determines the host. Symlinks are resolved on both sides for macOS compatibility (/tmp → /private/tmp). Also adds --limit/-L flag to repo list. --- cmd/actions.go | 36 +++++----- cmd/api.go | 2 +- cmd/issue.go | 16 ++--- cmd/label.go | 2 +- cmd/milestone.go | 12 ++-- cmd/pr.go | 16 ++--- cmd/pr_checks.go | 2 +- cmd/pr_diff.go | 2 +- cmd/pr_review.go | 4 +- cmd/release.go | 12 ++-- cmd/repo.go | 22 +++--- cmd/root.go | 9 +++ cmd/wiki.go | 2 +- internal/api/client.go | 4 +- internal/api/client_test.go | 2 +- internal/config/config.go | 63 +++++++++++++++-- internal/config/config_test.go | 125 ++++++++++++++++++++++++++++++--- 17 files changed, 252 insertions(+), 79 deletions(-) diff --git a/cmd/actions.go b/cmd/actions.go index fbb0d75..dc1ebe5 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -420,7 +420,7 @@ func runRunList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -469,7 +469,7 @@ func runRunView(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -668,7 +668,7 @@ func runRunWatch(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -718,7 +718,7 @@ func runRunRerun(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -749,7 +749,7 @@ func runRunCancel(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -866,7 +866,7 @@ func runWorkflowList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -939,7 +939,7 @@ func runWorkflowView(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -1000,7 +1000,7 @@ func runWorkflowRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -1077,7 +1077,7 @@ func runWorkflowEnable(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -1122,7 +1122,7 @@ func runWorkflowDisable(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -1206,7 +1206,7 @@ func runActionsSecretList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -1241,7 +1241,7 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -1282,7 +1282,7 @@ func runActionsSecretDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -1318,7 +1318,7 @@ func runActionsVariableList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -1359,7 +1359,7 @@ func runActionsVariableGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -1387,7 +1387,7 @@ func runActionsVariableCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -1416,7 +1416,7 @@ func runActionsVariableUpdate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } @@ -1445,7 +1445,7 @@ func runActionsVariableDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } diff --git a/cmd/api.go b/cmd/api.go index 873f16b..758efdd 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -72,7 +72,7 @@ func runAPI(cmd *cobra.Command, args []string) error { detectedHost := getDetectedHost() - host, err := cfg.GetHost(hostname, detectedHost) + host, err := cfg.GetHost(hostname, detectedHost, getCwd()) if err != nil { return err } diff --git a/cmd/issue.go b/cmd/issue.go index df752d4..c5d80b8 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -203,7 +203,7 @@ func runIssueList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -275,7 +275,7 @@ func runIssueView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -375,7 +375,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -464,7 +464,7 @@ func runIssueComment(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -503,7 +503,7 @@ func runIssueClose(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -564,7 +564,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -687,7 +687,7 @@ func runIssueDelete(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -732,7 +732,7 @@ func runIssueReopen(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } diff --git a/cmd/label.go b/cmd/label.go index 83966d8..4361414 100644 --- a/cmd/label.go +++ b/cmd/label.go @@ -116,7 +116,7 @@ func newLabelClient(cmd *cobra.Command) (*api.Client, string, string, error) { return nil, "", "", err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return nil, "", "", err } diff --git a/cmd/milestone.go b/cmd/milestone.go index 2674a3f..2c38835 100644 --- a/cmd/milestone.go +++ b/cmd/milestone.go @@ -183,7 +183,7 @@ func runMilestoneList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -249,7 +249,7 @@ func runMilestoneView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -264,7 +264,7 @@ func runMilestoneView(cmd *cobra.Command, args []string) error { if web, _ := cmd.Flags().GetBool("web"); web { // Milestones don't have HTMLURL in the API, construct it cfg2, _ := config.Load() - host, _ := cfg2.GetHost("", getDetectedHost()) + host, _ := cfg2.GetHost("", getDetectedHost(), getCwd()) url := fmt.Sprintf("https://%s/%s/%s/milestone/%d", host.Hostname, owner, name, ms.ID) return ios.OpenInBrowser(url) } @@ -315,7 +315,7 @@ func runMilestoneCreate(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -363,7 +363,7 @@ func runMilestoneEdit(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -450,7 +450,7 @@ func runMilestoneDelete(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } diff --git a/cmd/pr.go b/cmd/pr.go index 72bbb9f..42d3d6f 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -228,7 +228,7 @@ func runPRList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -367,7 +367,7 @@ func runPRView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -500,7 +500,7 @@ func runPRCreate(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -611,7 +611,7 @@ func runPRMerge(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -678,7 +678,7 @@ func runPRClose(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -727,7 +727,7 @@ func runPRReopen(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -766,7 +766,7 @@ func runPRCheckout(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -886,7 +886,7 @@ func runPREdit(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } diff --git a/cmd/pr_checks.go b/cmd/pr_checks.go index 4198668..f781f95 100644 --- a/cmd/pr_checks.go +++ b/cmd/pr_checks.go @@ -46,7 +46,7 @@ func runPRChecks(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } diff --git a/cmd/pr_diff.go b/cmd/pr_diff.go index cee3fce..f91add4 100644 --- a/cmd/pr_diff.go +++ b/cmd/pr_diff.go @@ -58,7 +58,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } diff --git a/cmd/pr_review.go b/cmd/pr_review.go index ea370bf..ab2c8a7 100644 --- a/cmd/pr_review.go +++ b/cmd/pr_review.go @@ -121,7 +121,7 @@ func runPRComment(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -191,7 +191,7 @@ func runPRReview(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } diff --git a/cmd/release.go b/cmd/release.go index 7f3ffd4..ba7c29d 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -180,7 +180,7 @@ func runReleaseList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -251,7 +251,7 @@ func runReleaseView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -363,7 +363,7 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -420,7 +420,7 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -460,7 +460,7 @@ func runReleaseDownload(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -547,7 +547,7 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } diff --git a/cmd/repo.go b/cmd/repo.go index d216198..620d942 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -125,6 +125,7 @@ func init() { repoViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") addJSONFlags(repoListCmd, "Output repositories as JSON") + repoListCmd.Flags().IntP("limit", "L", 0, "Maximum number of repositories to list (0 = no limit)") repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh") @@ -158,7 +159,7 @@ func runRepoView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -203,7 +204,7 @@ func runRepoList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -221,6 +222,11 @@ func runRepoList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to list repositories: %w", err) } + limit, _ := cmd.Flags().GetInt("limit") + if limit > 0 && len(repos) > limit { + repos = repos[:limit] + } + if wantJSON(cmd) { return outputJSON(cmd, repos) } @@ -257,7 +263,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -324,7 +330,7 @@ func runRepoFork(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -371,7 +377,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -439,7 +445,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error { if doClone { cloneURL := repo.CloneURL - if hostCfg, hostErr := cfg.GetHost("", getDetectedHost()); hostErr == nil { + if hostCfg, hostErr := cfg.GetHost("", getDetectedHost(), getCwd()); hostErr == nil { if hostCfg.GitProtocol == "ssh" { cloneURL = repo.SSHURL } @@ -494,7 +500,7 @@ func runRepoEdit(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } @@ -591,7 +597,7 @@ func runRepoRename(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index edb1c39..26b3ff0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -96,6 +96,15 @@ func getDetectedHost() string { return host } +// getCwd returns the current working directory, or "" on error. +func getCwd() string { + cwd, err := os.Getwd() + if err != nil { + return "" + } + return cwd +} + // promptLine prints a prompt to stderr and reads a line from stdin. func promptLine(prompt string) (string, error) { fmt.Fprint(ios.ErrOut, prompt) diff --git a/cmd/wiki.go b/cmd/wiki.go index 4c2d65d..3992c86 100644 --- a/cmd/wiki.go +++ b/cmd/wiki.go @@ -185,7 +185,7 @@ func newWikiClient(cmd *cobra.Command) (*api.Client, string, string, error) { return nil, "", "", err } - client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return nil, "", "", err } diff --git a/internal/api/client.go b/internal/api/client.go index d5869d3..d91ca36 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -39,8 +39,8 @@ func NewClient(hostname, token string) (*Client, error) { }, nil } -func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string) (*Client, error) { - host, err := cfg.GetHost(hostname, detectedHost) +func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string, cwd string) (*Client, error) { + host, err := cfg.GetHost(hostname, detectedHost, cwd) if err != nil { return nil, err } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index daf8b3c..31146e1 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -21,7 +21,7 @@ func TestNewClientFromConfig_MissingHost(t *testing.T) { Hosts: map[string]config.HostConfig{}, } - _, err := NewClientFromConfig(cfg, "nonexistent.org", "") + _, err := NewClientFromConfig(cfg, "nonexistent.org", "", "") if err == nil { t.Error("Expected error for nonexistent host") } diff --git a/internal/config/config.go b/internal/config/config.go index 2f22243..bc42159 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/spf13/viper" "gopkg.in/yaml.v3" @@ -14,10 +15,11 @@ type Config struct { } type HostConfig struct { - Hostname string `yaml:"hostname"` - Token string `yaml:"token"` - User string `yaml:"user,omitempty"` - GitProtocol string `yaml:"git_protocol,omitempty"` + Hostname string `yaml:"hostname"` + Token string `yaml:"token"` + User string `yaml:"user,omitempty"` + GitProtocol string `yaml:"git_protocol,omitempty"` + MatchDirs []string `yaml:"match_dirs,omitempty"` } func GetConfigDir() (string, error) { @@ -96,8 +98,9 @@ func (c *Config) SaveToPath(path string) error { // 2. CLI flag (--hostname) // 3. Environment variable (FGJ_HOST) // 4. Auto-detected hostname from git remote -// 5. Default to codeberg.org -func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, error) { +// 5. match_dirs lookup (longest prefix match) +// 6. Default to codeberg.org +func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (HostConfig, error) { if hostname == "" { hostname = viper.GetString("hostname") } @@ -110,6 +113,10 @@ func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, erro hostname = detectedHost } + if hostname == "" { + hostname = c.ResolveHostByPath(cwd) + } + if hostname == "" { hostname = "codeberg.org" } @@ -122,6 +129,50 @@ func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, erro return host, nil } +// ResolveHostByPath finds the host whose match_dirs entry is the longest +// prefix of cwd. Returns "" if no match is found. +// Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks +// to handle symlinks (e.g. macOS /tmp → /private/tmp). +func (c *Config) ResolveHostByPath(cwd string) string { + if cwd == "" { + return "" + } + + // Resolve symlinks in cwd so /tmp becomes /private/tmp on macOS, etc. + if resolved, err := filepath.EvalSymlinks(cwd); err == nil { + cwd = resolved + } + + bestHost := "" + bestLen := 0 + + for hostname, host := range c.Hosts { + for _, dir := range host.MatchDirs { + if dir == "" { + continue + } + // Resolve symlinks in the configured dir as well + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + // Normalize: ensure trailing slash for prefix matching + prefix := dir + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + // Match if cwd equals dir exactly or is under it + if cwd == dir || strings.HasPrefix(cwd, prefix) { + if len(dir) > bestLen { + bestLen = len(dir) + bestHost = hostname + } + } + } + } + + return bestHost +} + func (c *Config) SetHost(hostname string, host HostConfig) { if c.Hosts == nil { c.Hosts = make(map[string]HostConfig) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3d55ee9..95282f2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -49,7 +49,7 @@ func TestConfig_GetHost(t *testing.T) { }, } - host, err := cfg.GetHost("codeberg.org", "") + host, err := cfg.GetHost("codeberg.org", "", "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -58,7 +58,7 @@ func TestConfig_GetHost(t *testing.T) { t.Errorf("Expected hostname 'codeberg.org', got '%s'", host.Hostname) } - _, err = cfg.GetHost("nonexistent.org", "") + _, err = cfg.GetHost("nonexistent.org", "", "") if err == nil { t.Error("Expected error for nonexistent host") } @@ -275,7 +275,7 @@ func TestConfig_GetHost_EmptyString(t *testing.T) { } // Empty hostname should default to codeberg.org - host, err := cfg.GetHost("", "") + host, err := cfg.GetHost("", "", "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -296,7 +296,7 @@ func TestConfig_GetHost_WhitespaceString(t *testing.T) { } // Whitespace-only hostname should default to codeberg.org - host, err := cfg.GetHost(" ", "") + host, err := cfg.GetHost(" ", "", "") if err == nil { t.Logf("Got host: %+v (this may be expected behavior)", host) } else { @@ -315,7 +315,7 @@ func TestConfig_SetHost_EmptyToken(t *testing.T) { cfg.SetHost("codeberg.org", hostConfig) - host, err := cfg.GetHost("codeberg.org", "") + host, err := cfg.GetHost("codeberg.org", "", "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -345,7 +345,7 @@ func TestConfig_SetHost_OverwriteExisting(t *testing.T) { cfg.SetHost("codeberg.org", newConfig) - host, err := cfg.GetHost("codeberg.org", "") + host, err := cfg.GetHost("codeberg.org", "", "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -388,7 +388,7 @@ func TestConfig_MultipleHosts(t *testing.T) { // Verify each host can be retrieved correctly for _, h := range hosts { - host, err := cfg.GetHost(h.hostname, "") + host, err := cfg.GetHost(h.hostname, "", "") if err != nil { t.Errorf("Failed to get host %s: %v", h.hostname, err) continue @@ -422,13 +422,120 @@ func TestConfig_GitProtocol(t *testing.T) { }) // Verify protocols are stored correctly - sshHost, _ := cfg.GetHost("test-ssh.org", "") + sshHost, _ := cfg.GetHost("test-ssh.org", "", "") if sshHost.GitProtocol != "ssh" { t.Errorf("Expected git_protocol 'ssh', got '%s'", sshHost.GitProtocol) } - httpsHost, _ := cfg.GetHost("test-https.org", "") + httpsHost, _ := cfg.GetHost("test-https.org", "", "") if httpsHost.GitProtocol != "https" { t.Errorf("Expected git_protocol 'https', got '%s'", httpsHost.GitProtocol) } } + +func TestResolveHostByPath(t *testing.T) { + cfg := &Config{ + Hosts: map[string]HostConfig{ + "forgejo.zerova.net": { + Hostname: "forgejo.zerova.net", + Token: "token1", + MatchDirs: []string{"/Users/sid/repos/fgj", "/Users/sid/repos/zerova"}, + }, + "codeberg.org": { + Hostname: "codeberg.org", + Token: "token2", + MatchDirs: []string{"/"}, + }, + "gitea.example.com": { + Hostname: "gitea.example.com", + Token: "token3", + // no match_dirs — should never be selected by path + }, + }, + } + + tests := []struct { + name string + cwd string + want string + }{ + {"exact dir match", "/Users/sid/repos/fgj", "forgejo.zerova.net"}, + {"nested dir match", "/Users/sid/repos/fgj/cmd/root.go", "forgejo.zerova.net"}, + {"second match dir", "/Users/sid/repos/zerova/pkg", "forgejo.zerova.net"}, + {"longest prefix wins over /", "/Users/sid/repos/fgj/internal", "forgejo.zerova.net"}, + {"/ as global catch-all", "/tmp", "codeberg.org"}, + {"/ matches root itself", "/", "codeberg.org"}, + {"no match_dirs host not selected", "/some/random/path", "codeberg.org"}, + {"empty cwd returns empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cfg.ResolveHostByPath(tt.cwd) + if got != tt.want { + t.Errorf("ResolveHostByPath(%q) = %q, want %q", tt.cwd, got, tt.want) + } + }) + } +} + +func TestResolveHostByPath_LongestPrefixAcrossHosts(t *testing.T) { + cfg := &Config{ + Hosts: map[string]HostConfig{ + "broad.org": { + Hostname: "broad.org", + Token: "t1", + MatchDirs: []string{"/Users/sid"}, + }, + "specific.org": { + Hostname: "specific.org", + Token: "t2", + MatchDirs: []string{"/Users/sid/repos/myproject"}, + }, + }, + } + + got := cfg.ResolveHostByPath("/Users/sid/repos/myproject/main.go") + if got != "specific.org" { + t.Errorf("expected specific.org, got %q", got) + } + + got = cfg.ResolveHostByPath("/Users/sid/other") + if got != "broad.org" { + t.Errorf("expected broad.org, got %q", got) + } +} + +func TestGetHost_MatchDirsIntegration(t *testing.T) { + cfg := &Config{ + Hosts: map[string]HostConfig{ + "forgejo.zerova.net": { + Hostname: "forgejo.zerova.net", + Token: "token1", + MatchDirs: []string{"/Users/sid/repos/fgj"}, + }, + "codeberg.org": { + Hostname: "codeberg.org", + Token: "token2", + }, + }, + } + + // cwd match should resolve to forgejo.zerova.net + host, err := cfg.GetHost("", "", "/Users/sid/repos/fgj/cmd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host.Hostname != "forgejo.zerova.net" { + t.Errorf("expected forgejo.zerova.net, got %s", host.Hostname) + } + + // no cwd match falls through to codeberg.org default + host, err = cfg.GetHost("", "", "/tmp") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host.Hostname != "codeberg.org" { + t.Errorf("expected codeberg.org, got %s", host.Hostname) + } +} From ac780231a8e6a06a77cc80e8f9a84419b945b439 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 23 Mar 2026 12:50:49 -0600 Subject: [PATCH 04/20] feat: support ~ (tilde) expansion in match_dirs paths --- internal/config/config.go | 14 +++++++++ internal/config/config_test.go | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index bc42159..8fec3fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -151,6 +151,8 @@ func (c *Config) ResolveHostByPath(cwd string) string { if dir == "" { continue } + // Expand ~ to home directory + dir = expandHome(dir) // Resolve symlinks in the configured dir as well if resolved, err := filepath.EvalSymlinks(dir); err == nil { dir = resolved @@ -173,6 +175,18 @@ func (c *Config) ResolveHostByPath(cwd string) string { return bestHost } +// expandHome replaces a leading ~ with the user's home directory. +func expandHome(path string) string { + if path == "~" || strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return path + } + return filepath.Join(home, path[1:]) + } + return path +} + func (c *Config) SetHost(hostname string, host HostConfig) { if c.Hosts == nil { c.Hosts = make(map[string]HostConfig) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 95282f2..af2d79e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -539,3 +539,55 @@ func TestGetHost_MatchDirsIntegration(t *testing.T) { t.Errorf("expected codeberg.org, got %s", host.Hostname) } } + +func TestResolveHostByPath_TildeExpansion(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("cannot determine home directory") + } + + cfg := &Config{ + Hosts: map[string]HostConfig{ + "tilde.org": { + Hostname: "tilde.org", + Token: "t1", + MatchDirs: []string{"~/repos"}, + }, + }, + } + + got := cfg.ResolveHostByPath(filepath.Join(home, "repos", "myproject")) + if got != "tilde.org" { + t.Errorf("expected tilde.org, got %q", got) + } + + got = cfg.ResolveHostByPath(filepath.Join(home, "other")) + if got != "" { + t.Errorf("expected empty, got %q", got) + } +} + +func TestExpandHome(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("cannot determine home directory") + } + + tests := []struct { + input string + want string + }{ + {"~/repos", filepath.Join(home, "repos")}, + {"~", home}, + {"/absolute/path", "/absolute/path"}, + {"relative/path", "relative/path"}, + {"~other", "~other"}, // only ~/... is expanded, not ~user + } + + for _, tt := range tests { + got := expandHome(tt.input) + if got != tt.want { + t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} From 830eba1c0ede232d715497e2c52539846169e770 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 23 Mar 2026 12:55:49 -0600 Subject: [PATCH 05/20] feat: deterministic match_dirs tie-breaking by config file order --- internal/config/config.go | 52 +++++++++++++++++++++++++++++ internal/config/config_test.go | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 8fec3fa..e60583d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type HostConfig struct { User string `yaml:"user,omitempty"` GitProtocol string `yaml:"git_protocol,omitempty"` MatchDirs []string `yaml:"match_dirs,omitempty"` + Order int `yaml:"-"` // config file order, set at load time } func GetConfigDir() (string, error) { @@ -67,9 +68,43 @@ func LoadFromPath(path string) (*Config, error) { cfg.Hosts = make(map[string]HostConfig) } + // Parse again with yaml.Node to capture config file order for hosts + assignHostOrder(&cfg, data) + return &cfg, nil } +// assignHostOrder walks the YAML document tree to find the "hosts" mapping +// and stamps each HostConfig.Order with its position in the file. +func assignHostOrder(cfg *Config, data []byte) { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil || len(doc.Content) == 0 { + return + } + root := doc.Content[0] // mapping node + if root.Kind != yaml.MappingNode { + return + } + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == "hosts" { + hostsNode := root.Content[i+1] + if hostsNode.Kind != yaml.MappingNode { + return + } + order := 0 + for j := 0; j+1 < len(hostsNode.Content); j += 2 { + key := hostsNode.Content[j].Value + if h, ok := cfg.Hosts[key]; ok { + h.Order = order + cfg.Hosts[key] = h + order++ + } + } + return + } + } +} + func (c *Config) Save() error { path, err := GetConfigPath() if err != nil { @@ -133,6 +168,8 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host // prefix of cwd. Returns "" if no match is found. // Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks // to handle symlinks (e.g. macOS /tmp → /private/tmp). +// On ties (same prefix length from multiple hosts), the host appearing first +// in the config file wins and a warning is printed to stderr. func (c *Config) ResolveHostByPath(cwd string) string { if cwd == "" { return "" @@ -145,6 +182,8 @@ func (c *Config) ResolveHostByPath(cwd string) string { bestHost := "" bestLen := 0 + bestOrder := 0 + tied := false for hostname, host := range c.Hosts { for _, dir := range host.MatchDirs { @@ -167,11 +206,24 @@ func (c *Config) ResolveHostByPath(cwd string) string { if len(dir) > bestLen { bestLen = len(dir) bestHost = hostname + bestOrder = host.Order + tied = false + } else if len(dir) == bestLen && hostname != bestHost { + // Tie — pick the host with the lower Order (earlier in config) + if host.Order < bestOrder { + bestHost = hostname + bestOrder = host.Order + } + tied = true } } } } + if tied { + fmt.Fprintf(os.Stderr, "warning: multiple hosts match directory %q with the same specificity; using %s (first in config)\n", cwd, bestHost) + } + return bestHost } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index af2d79e..8ceae65 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -567,6 +567,67 @@ func TestResolveHostByPath_TildeExpansion(t *testing.T) { } } +func TestResolveHostByPath_TieBreakByConfigOrder(t *testing.T) { + cfg := &Config{ + Hosts: map[string]HostConfig{ + "second.org": { + Hostname: "second.org", + Token: "t2", + MatchDirs: []string{"/shared/path"}, + Order: 1, + }, + "first.org": { + Hostname: "first.org", + Token: "t1", + MatchDirs: []string{"/shared/path"}, + Order: 0, + }, + }, + } + + got := cfg.ResolveHostByPath("/shared/path/subdir") + if got != "first.org" { + t.Errorf("expected first.org (earlier in config), got %q", got) + } +} + +func TestAssignHostOrder(t *testing.T) { + yamlData := []byte(`hosts: + alpha.org: + hostname: alpha.org + token: t1 + beta.org: + hostname: beta.org + token: t2 + gamma.org: + hostname: gamma.org + token: t3 +`) + cfg, err := LoadFromPath(writeTempConfig(t, yamlData)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Hosts["alpha.org"].Order != 0 { + t.Errorf("alpha.org order = %d, want 0", cfg.Hosts["alpha.org"].Order) + } + if cfg.Hosts["beta.org"].Order != 1 { + t.Errorf("beta.org order = %d, want 1", cfg.Hosts["beta.org"].Order) + } + if cfg.Hosts["gamma.org"].Order != 2 { + t.Errorf("gamma.org order = %d, want 2", cfg.Hosts["gamma.org"].Order) + } +} + +func writeTempConfig(t *testing.T, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatalf("failed to write temp config: %v", err) + } + return path +} + func TestExpandHome(t *testing.T) { home, err := os.UserHomeDir() if err != nil { From 4669d21deade4775c7f905b29b1cdd82c11ed3ca Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 23 Mar 2026 12:57:38 -0600 Subject: [PATCH 06/20] chore: bump version to 0.3.0e --- cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 26b3ff0..1233c9a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,7 +19,7 @@ var rootCmd = &cobra.Command{ Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line", Long: `fgj is a command line tool for Forgejo instances (including Codeberg). It brings pull requests, issues, and other Forgejo concepts to the terminal.`, - Version: "0.3.0c", + Version: "0.3.0e", SilenceErrors: true, } From 2e6575c660cf2870c990ca04cf04494a37b89351 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 23 Mar 2026 13:11:43 -0600 Subject: [PATCH 07/20] docs: document match_dirs for directory-based host selection The match_dirs config option was undocumented. Add a dedicated section explaining directory-based host selection with examples, and update the hostname resolution priority list to include match_dirs at step 4. --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9da291f..d0ba488 100644 --- a/README.md +++ b/README.md @@ -453,13 +453,45 @@ hosts: token: your_token_here user: your_username git_protocol: ssh + match_dirs: + - / # catch-all: use this host when no git remote is detected codeberg.org: hostname: codeberg.org token: another_token user: another_username git_protocol: https + match_dirs: + - ~/repos/codeberg # use this host for repos under this directory ``` +### Directory-Based Host Selection (`match_dirs`) + +When you work with multiple Forgejo/Gitea instances, `fgj` can automatically select the right host based on your current working directory — no `--hostname` flag needed. + +Each host entry supports a `match_dirs` list of directory paths. When `fgj` can't determine the host from a git remote, it finds the host whose `match_dirs` entry is the **longest prefix match** for your current directory. + +```yaml +hosts: + work.example.com: + # ... + match_dirs: + - ~/work # any repo under ~/work uses this host + personal.example.com: + # ... + match_dirs: + - ~/personal + - ~/side-projects # multiple directories can map to the same host + codeberg.org: + # ... + match_dirs: + - / # catch-all fallback (shortest prefix, lowest priority) +``` + +- Paths support `~` expansion and symlink resolution +- More specific (longer) paths always win over shorter ones +- Use `/` as a catch-all to override the default `codeberg.org` fallback +- On ties (same prefix length), the host appearing first in the config file wins + ### Environment Variables - `FGJ_HOST`: Override the default instance (auto-detected from git remote if not set) @@ -469,7 +501,8 @@ Hostname is resolved in this priority order: 1. Command-specific flags (e.g., `--hostname`) 2. `FGJ_HOST` environment variable 3. Auto-detected from git remote URL -4. Default to `codeberg.org` +4. `match_dirs` lookup (longest prefix match against current directory) +5. Default to `codeberg.org` ### Command-line Flags From c2251d9932b9bc3014233f97cfe1c43249a0e22c Mon Sep 17 00:00:00 2001 From: sid Date: Sat, 11 Apr 2026 10:34:34 -0600 Subject: [PATCH 08/20] chore: migrate module path to public org Move from forgejo.zerova.net/sid/fgj-sid to forgejo.zerova.net/public/fgj-sid to reflect the new public org. --- CHANGELOG.md | 6 +++--- README.md | 8 ++++---- cmd/actions.go | 4 ++-- cmd/api.go | 4 ++-- cmd/auth.go | 4 ++-- cmd/errors.go | 2 +- cmd/ios_init.go | 2 +- cmd/issue.go | 6 +++--- cmd/label.go | 4 ++-- cmd/milestone.go | 6 +++--- cmd/pr.go | 8 ++++---- cmd/pr_checks.go | 6 +++--- cmd/pr_diff.go | 4 ++-- cmd/pr_review.go | 4 ++-- cmd/release.go | 6 +++--- cmd/repo.go | 6 +++--- cmd/root.go | 2 +- cmd/wiki.go | 6 +++--- go.mod | 2 +- internal/api/client.go | 2 +- internal/api/client_test.go | 2 +- main.go | 2 +- 22 files changed, 48 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a61045..90f337c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,9 +203,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cobra framework for CLI structure - Viper for configuration management -[0.3.0c]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0c -[0.3.0b]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0b -[0.3.0a]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0a +[0.3.0c]: https://forgejo.zerova.net/public/fgj-sid/releases/tag/v0.3.0c +[0.3.0b]: https://forgejo.zerova.net/public/fgj-sid/releases/tag/v0.3.0b +[0.3.0a]: https://forgejo.zerova.net/public/fgj-sid/releases/tag/v0.3.0a [0.3.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.3.0 [0.2.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.2.0 [0.1.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.1.0 diff --git a/README.md b/README.md index d0ba488..770b787 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ `fgj` is a command-line tool for working with Forgejo and Gitea instances. It brings pull requests, issues, and other forge concepts to the terminal, similar to what `gh` does for GitHub. This fork adds agentic dev features — raw API access, PR review workflows, structured error output, and machine-readable I/O for AI coding agents. -> Forked from [codeberg.org/romaintb/fgj](https://codeberg.org/romaintb/fgj) and hosted at [forgejo.zerova.net/sid/fgj-sid](https://forgejo.zerova.net/sid/fgj-sid). +> Forked from [codeberg.org/romaintb/fgj](https://codeberg.org/romaintb/fgj) and hosted at [forgejo.zerova.net/public/fgj-sid](https://forgejo.zerova.net/public/fgj-sid). ## Features @@ -40,13 +40,13 @@ brew install fgj ### Using Go Install ```bash -go install forgejo.zerova.net/sid/fgj-sid@latest +go install forgejo.zerova.net/public/fgj-sid@latest ``` ### From Source ```bash -git clone https://forgejo.zerova.net/sid/fgj-sid.git +git clone https://forgejo.zerova.net/public/fgj-sid.git cd fgj-sid go build -o fgj . ``` @@ -557,7 +557,7 @@ fgj pr view 9999 --json --json-errors 2>errors.json ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request at [forgejo.zerova.net/sid/fgj-sid](https://forgejo.zerova.net/sid/fgj-sid). +Contributions are welcome! Please feel free to submit a Pull Request at [forgejo.zerova.net/public/fgj-sid](https://forgejo.zerova.net/public/fgj-sid). ## Missing Features / Roadmap diff --git a/cmd/actions.go b/cmd/actions.go index dc1ebe5..c1418ad 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -10,8 +10,8 @@ import ( "code.gitea.io/sdk/gitea" "github.com/spf13/cobra" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" ) // ActionRun represents a workflow run diff --git a/cmd/api.go b/cmd/api.go index 758efdd..f5beba4 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -10,8 +10,8 @@ import ( "strconv" "strings" - "forgejo.zerova.net/sid/fgj-sid/internal/config" - "forgejo.zerova.net/sid/fgj-sid/internal/git" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/git" "github.com/spf13/cobra" ) diff --git a/cmd/auth.go b/cmd/auth.go index 832e14d..a6b3c74 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -7,8 +7,8 @@ import ( "strings" "syscall" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" "github.com/spf13/cobra" "github.com/spf13/viper" "golang.org/x/term" diff --git a/cmd/errors.go b/cmd/errors.go index 45ae602..e303e6e 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "forgejo.zerova.net/sid/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/api" ) // Error codes for structured error output. diff --git a/cmd/ios_init.go b/cmd/ios_init.go index f4846e5..9d36650 100644 --- a/cmd/ios_init.go +++ b/cmd/ios_init.go @@ -1,5 +1,5 @@ package cmd -import "forgejo.zerova.net/sid/fgj-sid/internal/iostreams" +import "forgejo.zerova.net/public/fgj-sid/internal/iostreams" var ios = iostreams.New() diff --git a/cmd/issue.go b/cmd/issue.go index c5d80b8..f053b1a 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -6,9 +6,9 @@ import ( "strings" "code.gitea.io/sdk/gitea" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" - "forgejo.zerova.net/sid/fgj-sid/internal/text" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/text" "github.com/spf13/cobra" ) diff --git a/cmd/label.go b/cmd/label.go index 4361414..87cea75 100644 --- a/cmd/label.go +++ b/cmd/label.go @@ -5,8 +5,8 @@ import ( "strings" "code.gitea.io/sdk/gitea" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" "github.com/spf13/cobra" ) diff --git a/cmd/milestone.go b/cmd/milestone.go index 2c38835..ce9813c 100644 --- a/cmd/milestone.go +++ b/cmd/milestone.go @@ -7,9 +7,9 @@ import ( "time" "code.gitea.io/sdk/gitea" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" - "forgejo.zerova.net/sid/fgj-sid/internal/text" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/text" "github.com/spf13/cobra" ) diff --git a/cmd/pr.go b/cmd/pr.go index 42d3d6f..3ffbfad 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -7,10 +7,10 @@ import ( "strings" "code.gitea.io/sdk/gitea" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" - gitpkg "forgejo.zerova.net/sid/fgj-sid/internal/git" - "forgejo.zerova.net/sid/fgj-sid/internal/text" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + gitpkg "forgejo.zerova.net/public/fgj-sid/internal/git" + "forgejo.zerova.net/public/fgj-sid/internal/text" "github.com/spf13/cobra" ) diff --git a/cmd/pr_checks.go b/cmd/pr_checks.go index f781f95..f475524 100644 --- a/cmd/pr_checks.go +++ b/cmd/pr_checks.go @@ -4,9 +4,9 @@ import ( "fmt" "code.gitea.io/sdk/gitea" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" - "forgejo.zerova.net/sid/fgj-sid/internal/iostreams" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/iostreams" "github.com/spf13/cobra" ) diff --git a/cmd/pr_diff.go b/cmd/pr_diff.go index f91add4..414669f 100644 --- a/cmd/pr_diff.go +++ b/cmd/pr_diff.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" "github.com/spf13/cobra" ) diff --git a/cmd/pr_review.go b/cmd/pr_review.go index ab2c8a7..94115ff 100644 --- a/cmd/pr_review.go +++ b/cmd/pr_review.go @@ -6,8 +6,8 @@ import ( "os" "code.gitea.io/sdk/gitea" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" "github.com/spf13/cobra" ) diff --git a/cmd/release.go b/cmd/release.go index ba7c29d..b83dcd8 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -9,9 +9,9 @@ import ( "time" "code.gitea.io/sdk/gitea" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" - "forgejo.zerova.net/sid/fgj-sid/internal/text" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/text" "github.com/spf13/cobra" ) diff --git a/cmd/repo.go b/cmd/repo.go index 620d942..a9f62f6 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -8,9 +8,9 @@ import ( "strings" "code.gitea.io/sdk/gitea" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" - "forgejo.zerova.net/sid/fgj-sid/internal/text" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/text" "github.com/spf13/cobra" ) diff --git a/cmd/root.go b/cmd/root.go index 1233c9a..39739a1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "forgejo.zerova.net/sid/fgj-sid/internal/git" + "forgejo.zerova.net/public/fgj-sid/internal/git" "github.com/spf13/cobra" "github.com/spf13/viper" ) diff --git a/cmd/wiki.go b/cmd/wiki.go index 3992c86..36e2ce5 100644 --- a/cmd/wiki.go +++ b/cmd/wiki.go @@ -7,9 +7,9 @@ import ( "net/url" "time" - "forgejo.zerova.net/sid/fgj-sid/internal/api" - "forgejo.zerova.net/sid/fgj-sid/internal/config" - "forgejo.zerova.net/sid/fgj-sid/internal/text" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/text" "github.com/spf13/cobra" ) diff --git a/go.mod b/go.mod index b13d8ce..0ecd4a7 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module forgejo.zerova.net/sid/fgj-sid +module forgejo.zerova.net/public/fgj-sid go 1.24.0 diff --git a/internal/api/client.go b/internal/api/client.go index d91ca36..282a86a 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -9,7 +9,7 @@ import ( "time" "code.gitea.io/sdk/gitea" - "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/config" ) var sharedHTTPClient = &http.Client{ diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 31146e1..5b26675 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -3,7 +3,7 @@ package api import ( "testing" - "forgejo.zerova.net/sid/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/config" ) func TestClient_Hostname(t *testing.T) { diff --git a/main.go b/main.go index b3b29a0..f23476d 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "forgejo.zerova.net/sid/fgj-sid/cmd" + "forgejo.zerova.net/public/fgj-sid/cmd" ) func main() { From a6cf9a7096e0f9568ca3e0c4af87d96028a33080 Mon Sep 17 00:00:00 2001 From: sid Date: Sun, 19 Apr 2026 20:54:45 -0600 Subject: [PATCH 09/20] chore: bump version to 0.3.1 Restores installability via 'go install @latest'. Prior letter-suffix tags (v0.3.0a..v0.3.0f) aren't valid semver and were ignored by Go's module resolver, leaving @latest pointing at v0.3.0 which predates the module-path migration. --- cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 39739a1..8234142 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,7 +19,7 @@ var rootCmd = &cobra.Command{ Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line", Long: `fgj is a command line tool for Forgejo instances (including Codeberg). It brings pull requests, issues, and other Forgejo concepts to the terminal.`, - Version: "0.3.0e", + Version: "0.3.1", SilenceErrors: true, } From bc43f6e5a5571779387a2239a281f5a311abe931 Mon Sep 17 00:00:00 2001 From: sid Date: Sun, 26 Apr 2026 08:16:52 -0600 Subject: [PATCH 10/20] rename fgj to fj Module path, binary name, config dir, help text, and docs all updated from fgj-sid/fgj to fj. --- .gitea/workflows/ci.yml | 2 +- .gitea/workflows/nightly.yml | 2 +- CHANGELOG.md | 152 +++++++-------- Makefile | 4 +- README.md | 286 ++++++++++++++-------------- bin/fj | Bin 0 -> 18134680 bytes cmd/actions.go | 68 +++---- cmd/aliases.go | 2 +- cmd/api.go | 12 +- cmd/auth.go | 8 +- cmd/completion.go | 2 +- cmd/errors.go | 6 +- cmd/ios_init.go | 2 +- cmd/issue.go | 46 ++--- cmd/label.go | 28 +-- cmd/manpages.go | 2 +- cmd/milestone.go | 38 ++-- cmd/pr.go | 52 ++--- cmd/pr_checks.go | 10 +- cmd/pr_diff.go | 12 +- cmd/pr_review.go | 20 +- cmd/release.go | 44 ++--- cmd/repo.go | 24 +-- cmd/root.go | 10 +- cmd/wiki.go | 42 ++-- go.mod | 2 +- internal/api/client.go | 2 +- internal/api/client_test.go | 2 +- internal/config/config.go | 4 +- internal/config/config_test.go | 14 +- internal/git/git_test.go | 20 +- main.go | 2 +- tests/functional/fixtures.go | 10 +- tests/functional/functional_test.go | 28 +-- 34 files changed, 479 insertions(+), 479 deletions(-) create mode 100755 bin/fj diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cd989f4..ca2b95d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: - name: Build production binary run: | make build - echo "Binary built at: $(pwd)/bin/fgj" + echo "Binary built at: $(pwd)/bin/fj" - name: Run functional tests run: go test -v -race -tags=functional ./tests/functional/... diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index d335132..ef53290 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -24,7 +24,7 @@ jobs: - name: Build production binary run: | make build - echo "Binary built at: $(pwd)/bin/fgj" + echo "Binary built at: $(pwd)/bin/fj" - name: Run functional tests run: go test -v -race -tags=functional ./tests/functional/... diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f337c..466029f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,45 +10,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added #### Label Management -- `fgj label list` - List repository labels -- `fgj label create` - Create a label with color and description -- `fgj label edit` - Edit label name, color, or description -- `fgj label delete` - Delete a label +- `fj label list` - List repository labels +- `fj label create` - Create a label with color and description +- `fj label edit` - Edit label name, color, or description +- `fj label delete` - Delete a label #### Milestone Management -- `fgj milestone list` - List milestones with state filtering -- `fgj milestone view` - View milestone details -- `fgj milestone create` - Create a milestone with description and due date -- `fgj milestone edit` - Edit milestone title, description, due date, or state -- `fgj milestone delete` - Delete a milestone +- `fj milestone list` - List milestones with state filtering +- `fj milestone view` - View milestone details +- `fj milestone create` - Create a milestone with description and due date +- `fj milestone edit` - Edit milestone title, description, due date, or state +- `fj milestone delete` - Delete a milestone #### Wiki Management -- `fgj wiki list` - List wiki pages -- `fgj wiki view` - View wiki page content -- `fgj wiki create` - Create a wiki page from flag or file -- `fgj wiki edit` - Edit a wiki page -- `fgj wiki delete` - Delete a wiki page +- `fj wiki list` - List wiki pages +- `fj wiki view` - View wiki page content +- `fj wiki create` - Create a wiki page from flag or file +- `fj wiki edit` - Edit a wiki page +- `fj wiki delete` - Delete a wiki page #### Issue Dependencies -- `fgj issue edit --add-dependency ` - Add issue dependency -- `fgj issue edit --remove-dependency ` - Remove issue dependency +- `fj issue edit --add-dependency ` - Add issue dependency +- `fj issue edit --remove-dependency ` - Remove issue dependency ## [0.3.0b] - 2026-03-21 ### Added #### Repository Management -- `fgj repo edit` - Edit repository settings (visibility, description, homepage, default branch) +- `fj repo edit` - Edit repository settings (visibility, description, homepage, default branch) ### Fixed -- `fgj repo create --public` flag was defined but never read; now properly wired up +- `fj repo create --public` flag was defined but never read; now properly wired up ## [0.3.0a] - 2026-03-21 ### Added #### Raw API Access -- `fgj api ` - Make authenticated REST API requests to any Forgejo/Gitea endpoint +- `fj api ` - Make authenticated REST API requests to any Forgejo/Gitea endpoint - HTTP method selection (`--method`/`-X`), auto-switches to POST when fields are provided - JSON field assembly (`--field`/`-f`) with type inference (bool, int, float, null, string) - Raw string fields (`--raw-field`/`-F`) @@ -58,14 +58,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Response header display (`--include`/`-i`) #### Pull Request Management -- `fgj pr diff ` - View the diff for a pull request +- `fj pr diff ` - View the diff for a pull request - Colorized output (`--color auto/always/never`) - Changed file names only (`--name-only`) - Diffstat summary (`--stat`) -- `fgj pr comment ` - Add a comment to a pull request +- `fj pr comment ` - Add a comment to a pull request - Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin) - JSON output (`--json`) -- `fgj pr review ` - Submit a review on a pull request +- `fj pr review ` - Submit a review on a pull request - Approve (`--approve`/`-a`), request changes (`--request-changes`/`-r`), or comment (`--comment`/`-c`) - Body from flag or file - JSON output (`--json`) @@ -81,30 +81,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added #### Forgejo Actions -- `fgj actions run watch ` - Poll a run until completion -- `fgj actions run rerun ` - Trigger a rerun of a workflow run -- `fgj actions run cancel ` - Cancel an in-progress workflow run -- `fgj actions workflow enable ` - Enable a workflow -- `fgj actions workflow disable ` - Disable a workflow +- `fj actions run watch ` - Poll a run until completion +- `fj actions run rerun ` - Trigger a rerun of a workflow run +- `fj actions run cancel ` - Cancel an in-progress workflow run +- `fj actions workflow enable ` - Enable a workflow +- `fj actions workflow disable ` - Disable a workflow #### Repository Management -- `fgj repo create ` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team` +- `fj repo create ` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team` #### Issue Management -- `fgj issue create -l