diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a582277 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + lint: + runs-on: codeberg-small + steps: + - name: Checkout code + uses: https://github.com/actions/checkout@v4 + + - name: Set up Go + uses: https://github.com/actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + + - name: Install golangci-lint + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.6.2 + + - name: Run golangci-lint + run: golangci-lint run --timeout 5m + + build: + runs-on: codeberg-small + steps: + - name: Checkout code + uses: https://github.com/actions/checkout@v4 + + - name: Set up Go + uses: https://github.com/actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + + - name: Build application + run: make build + + test: + runs-on: codeberg-small + steps: + - name: Checkout code + uses: https://github.com/actions/checkout@v4 + + - name: Set up Go + uses: https://github.com/actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + + - name: Run tests + run: go test -v -race ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..bc67641 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,13 @@ +version: "2" + +run: + timeout: 5m + tests: true + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..57e8eda --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +.PHONY: help build run test clean lint lint-fix + +help: + @echo "Available commands:" + @echo " make build - Build the application" + @echo " make run - Run the application" + @echo " make test - Run tests" + @echo " make lint - Run golangci-lint" + @echo " make lint-fix - Run golangci-lint with auto-fix" + @echo " make clean - Clean build artifacts" + +build: + go build -o bin/fgj . + +run: + go run . + +test: + go test -v -race ./... + +lint: + golangci-lint run ./... + +lint-fix: + golangci-lint run --fix ./... + +clean: + rm -rf bin/ + go clean diff --git a/cmd/issue.go b/cmd/issue.go index 7cf33fd..1644143 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -124,13 +124,13 @@ func runIssueList(cmd *cobra.Command, args []string) error { } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n") + _, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n") for _, issue := range issues { if issue.PullRequest == nil { - fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State) + _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State) } } - w.Flush() + _ = w.Flush() return nil } diff --git a/cmd/pr.go b/cmd/pr.go index cb0071d..8091937 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -116,11 +116,11 @@ func runPRList(cmd *cobra.Command, args []string) error { } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "NUMBER\tTITLE\tBRANCH\tSTATE\n") + _, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tBRANCH\tSTATE\n") for _, pr := range prs { - fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", pr.Index, pr.Title, pr.Head.Ref, pr.State) + _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", pr.Index, pr.Title, pr.Head.Ref, pr.State) } - w.Flush() + _ = w.Flush() return nil } diff --git a/cmd/repo.go b/cmd/repo.go index 2b9bcfc..814f3c2 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -127,7 +127,7 @@ func runRepoList(cmd *cobra.Command, args []string) error { } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n") + _, _ = fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n") for _, repo := range repos { visibility := "public" if repo.Private { @@ -137,9 +137,9 @@ func runRepoList(cmd *cobra.Command, args []string) error { if len(desc) > 50 { desc = desc[:47] + "..." } - fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc) + _, _ = fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc) } - w.Flush() + _ = w.Flush() return nil } diff --git a/cmd/root.go b/cmd/root.go index f7b1581..34fef9c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fgj/config.yaml)") rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname") - viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname")) + _ = viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname")) } func initConfig() { @@ -41,7 +41,7 @@ func initConfig() { } configDir := home + "/.config/fgj" - os.MkdirAll(configDir, 0755) + _ = os.MkdirAll(configDir, 0755) viper.AddConfigPath(configDir) viper.SetConfigType("yaml") @@ -51,7 +51,5 @@ func initConfig() { viper.AutomaticEnv() viper.SetEnvPrefix("FGJ") - if err := viper.ReadInConfig(); err == nil { - // Config file found and successfully parsed - } + _ = viper.ReadInConfig() } diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..951a25b --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,28 @@ +package api + +import ( + "testing" + + "codeberg.org/romaintb/fgj/internal/config" +) + +func TestClient_Hostname(t *testing.T) { + client := &Client{ + hostname: "codeberg.org", + } + + if client.Hostname() != "codeberg.org" { + t.Errorf("Expected hostname 'codeberg.org', got '%s'", client.Hostname()) + } +} + +func TestNewClientFromConfig_MissingHost(t *testing.T) { + cfg := &config.Config{ + Hosts: map[string]config.HostConfig{}, + } + + _, 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 32f80ae..41c7409 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,7 +41,10 @@ func Load() (*Config, error) { if err != nil { return nil, err } + return LoadFromPath(path) +} +func LoadFromPath(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -67,7 +70,10 @@ func (c *Config) Save() error { if err != nil { return err } + return c.SaveToPath(path) +} +func (c *Config) SaveToPath(path string) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return err diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..90970b0 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,229 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfig_SetHost(t *testing.T) { + cfg := &Config{} + + hostConfig := HostConfig{ + Hostname: "codeberg.org", + Token: "test-token", + User: "testuser", + } + + cfg.SetHost("codeberg.org", hostConfig) + + if cfg.Hosts == nil { + t.Fatal("Hosts map should not be nil") + } + + if len(cfg.Hosts) != 1 { + t.Errorf("Expected 1 host, got %d", len(cfg.Hosts)) + } + + host, ok := cfg.Hosts["codeberg.org"] + if !ok { + t.Fatal("Host codeberg.org not found") + } + + if host.Hostname != "codeberg.org" { + t.Errorf("Expected hostname 'codeberg.org', got '%s'", host.Hostname) + } + + if host.Token != "test-token" { + t.Errorf("Expected token 'test-token', got '%s'", host.Token) + } +} + +func TestConfig_GetHost(t *testing.T) { + cfg := &Config{ + Hosts: map[string]HostConfig{ + "codeberg.org": { + Hostname: "codeberg.org", + Token: "test-token", + }, + }, + } + + host, err := cfg.GetHost("codeberg.org") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if host.Hostname != "codeberg.org" { + t.Errorf("Expected hostname 'codeberg.org', got '%s'", host.Hostname) + } + + _, err = cfg.GetHost("nonexistent.org") + if err == nil { + t.Error("Expected error for nonexistent host") + } +} + +func TestGetConfigDir(t *testing.T) { + dir, err := GetConfigDir() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if dir == "" { + t.Error("Config directory should not be empty") + } +} + +func TestGetConfigPath(t *testing.T) { + path, err := GetConfigPath() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if path == "" { + t.Error("Config path should not be empty") + } +} + +func TestConfig_SaveAndLoad(t *testing.T) { + // Create a temp directory for testing + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "config.yaml") + + // Create a config with test data + cfg := &Config{ + Hosts: map[string]HostConfig{ + "codeberg.org": { + Hostname: "codeberg.org", + Token: "test-token-123", + User: "testuser", + GitProtocol: "ssh", + }, + "github.com": { + Hostname: "github.com", + Token: "github-token-456", + User: "githubuser", + GitProtocol: "https", + }, + }, + } + + // Test Save + err := cfg.SaveToPath(tempFile) + if err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Verify file exists + if _, err := os.Stat(tempFile); os.IsNotExist(err) { + t.Fatal("Config file was not created") + } + + // Test Load + loadedCfg, err := LoadFromPath(tempFile) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Verify loaded config matches saved config + if len(loadedCfg.Hosts) != 2 { + t.Errorf("Expected 2 hosts, got %d", len(loadedCfg.Hosts)) + } + + // Check codeberg.org host + codebergHost, ok := loadedCfg.Hosts["codeberg.org"] + if !ok { + t.Fatal("codeberg.org host not found in loaded config") + } + if codebergHost.Token != "test-token-123" { + t.Errorf("Expected token 'test-token-123', got '%s'", codebergHost.Token) + } + if codebergHost.User != "testuser" { + t.Errorf("Expected user 'testuser', got '%s'", codebergHost.User) + } + if codebergHost.GitProtocol != "ssh" { + t.Errorf("Expected git_protocol 'ssh', got '%s'", codebergHost.GitProtocol) + } + + // Check github.com host + githubHost, ok := loadedCfg.Hosts["github.com"] + if !ok { + t.Fatal("github.com host not found in loaded config") + } + if githubHost.Token != "github-token-456" { + t.Errorf("Expected token 'github-token-456', got '%s'", githubHost.Token) + } +} + +func TestConfig_LoadNonexistentFile(t *testing.T) { + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "nonexistent.yaml") + + // Test Load with nonexistent file + cfg, err := LoadFromPath(tempFile) + if err != nil { + t.Fatalf("Load should not error on nonexistent file: %v", err) + } + + // Should return empty config + if cfg == nil { + t.Fatal("Config should not be nil") + } + + if cfg.Hosts == nil { + t.Fatal("Hosts map should be initialized") + } + + if len(cfg.Hosts) != 0 { + t.Errorf("Expected empty hosts map, got %d entries", len(cfg.Hosts)) + } +} + +func TestConfig_LoadInvalidYAML(t *testing.T) { + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "invalid.yaml") + + // Write invalid YAML + err := os.WriteFile(tempFile, []byte("invalid: yaml: content: [[["), 0600) + if err != nil { + t.Fatalf("Failed to write invalid YAML: %v", err) + } + + // Test Load with invalid YAML + _, err = LoadFromPath(tempFile) + if err == nil { + t.Error("Expected error when loading invalid YAML") + } +} + +func TestConfig_SaveCreatesDirectory(t *testing.T) { + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "subdir", "config.yaml") + + cfg := &Config{ + Hosts: map[string]HostConfig{ + "test.org": { + Hostname: "test.org", + Token: "token", + }, + }, + } + + // Save should create the directory + err := cfg.SaveToPath(tempFile) + if err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Verify directory was created + subdir := filepath.Dir(tempFile) + if _, err := os.Stat(subdir); os.IsNotExist(err) { + t.Error("Save() should have created the directory") + } + + // Verify file exists + if _, err := os.Stat(tempFile); os.IsNotExist(err) { + t.Error("Config file was not created") + } +}