diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index c4bb507..79d8d6b 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -41,14 +41,49 @@ jobs: echo "New tag: ${{ steps.tag_version.outputs.new_tag }}" echo "Tag created: ${{ steps.tag_version.outputs.new_tag != '' }}" + test: + name: Test + permissions: + contents: read + runs-on: ubuntu-latest + needs: auto-tag + # Always run tests, even if auto-tag was skipped + if: always() && (needs.auto-tag.result == 'success' || needs.auto-tag.result == 'skipped') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' # Use the appropriate Go version for your project + + - name: Get dependencies + run: go mod download + + - name: Auto-format code + run: | + # Auto-format all Go files + gofmt -s -w . + + # Check if any files were formatted + if [ -n "$(git status --porcelain 2>/dev/null)" ]; then + echo "Some files were auto-formatted in the build process." + git diff --name-only + fi + + - name: Run tests + run: go test -v ./... + build: name: Build Go Binary permissions: contents: read runs-on: ${{ matrix.os }} - needs: auto-tag - # Always run build, even if auto-tag was skipped (e.g., for tag pushes) - if: always() && (needs.auto-tag.result == 'success' || needs.auto-tag.result == 'skipped') + needs: [auto-tag, test] + # Only build if tests passed + if: always() && (needs.auto-tag.result == 'success' || needs.auto-tag.result == 'skipped') && needs.test.result == 'success' strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..38608d9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,110 @@ +name: Test +permissions: + contents: read + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + strategy: + matrix: + go-version: ['1.21', '1.22', '1.23'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-${{ matrix.go-version }}- + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Run go vet + run: go vet ./... + + - name: Run go fmt + run: | + # Auto-format all Go files + gofmt -s -w . + + # Check if any files were formatted (would indicate pre-existing formatting issues) + if [ -n "$(git status --porcelain 2>/dev/null)" ]; then + echo "Some files were auto-formatted. Consider running 'gofmt -s -w .' locally before committing." + git diff --name-only + fi + + # Final check to ensure all files are properly formatted + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "The following files are still not formatted after auto-format:" + gofmt -s -l . + exit 1 + fi + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Run tests with short mode + run: go test -v -short ./... + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.out + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Generate coverage report + run: go tool cover -html=coverage.out -o coverage.html + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report-go${{ matrix.go-version }} + path: coverage.html + + test-build: + name: Test Build + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Download dependencies + run: go mod download + + - name: Build + run: go build -v -o commit ./cmd/commit-msg + + - name: Verify binary + run: ./commit --help || true \ No newline at end of file diff --git a/cmd/cli/llmSetup.go b/cmd/cli/llmSetup.go index ca8531e..cccca3e 100644 --- a/cmd/cli/llmSetup.go +++ b/cmd/cli/llmSetup.go @@ -9,8 +9,6 @@ import ( "github.com/manifoldco/promptui" ) - - // SetupLLM walks the user through selecting an LLM provider and storing the // corresponding API key or endpoint configuration. func SetupLLM(Store *store.StoreMethods) error { diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 42efae5..4eb8b12 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -11,7 +11,7 @@ import ( var Store *store.StoreMethods //Initailize store -func StoreInit(sm *store.StoreMethods){ +func StoreInit(sm *store.StoreMethods) { Store = sm } @@ -92,7 +92,7 @@ func init() { // Cobra also supports local flags, which will only run // when this action is called directly. - + rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") // Add --dry-run and --auto as persistent flags so they show in top-level help diff --git a/cmd/cli/store/store.go b/cmd/cli/store/store.go index fecd5b8..97efa17 100644 --- a/cmd/cli/store/store.go +++ b/cmd/cli/store/store.go @@ -14,23 +14,20 @@ import ( StoreUtils "github.com/dfanso/commit-msg/utils" ) - - type StoreMethods struct { ring keyring.Keyring } //Initializes Keyring instance -func KeyringInit() (*StoreMethods, error){ +func KeyringInit() (*StoreMethods, error) { ring, err := keyring.Open(keyring.Config{ ServiceName: "commit-msg", }) if err != nil { return nil, fmt.Errorf("failed to open keyring: %w", err) } - return &StoreMethods{ring:ring},nil -} - + return &StoreMethods{ring: ring}, nil +} // LLMProvider represents a single stored LLM provider and its credential. type LLMProvider struct { @@ -38,14 +35,12 @@ type LLMProvider struct { APIKey string `json:"api_key"` } - // Config describes the on-disk structure for all saved LLM providers. type Config struct { - Default types.LLMProvider `json:"default"` - LLMProviders []types.LLMProvider `json:"models"` + Default types.LLMProvider `json:"default"` + LLMProviders []types.LLMProvider `json:"models"` } - // Save persists or updates an LLM provider entry, marking it as the default. func (s *StoreMethods) Save(LLMConfig LLMProvider) error { @@ -78,13 +73,12 @@ func (s *StoreMethods) Save(LLMConfig LLMProvider) error { } } - // If Model already present in config, update the apiKey updated := false for _, p := range cfg.LLMProviders { if p == LLMConfig.LLM { err := s.ring.Set(keyring.Item{ //save apiKey using keychain to OS credentials - Key: string(LLMConfig.LLM), + Key: string(LLMConfig.LLM), Data: []byte(LLMConfig.APIKey), }) if err != nil { @@ -98,13 +92,13 @@ func (s *StoreMethods) Save(LLMConfig LLMProvider) error { // If fresh Model is saved, means model not exists in config file if !updated { cfg.LLMProviders = append(cfg.LLMProviders, LLMConfig.LLM) - err := s.ring.Set(keyring.Item{ //save apiKey using keychain to OS credentials - Key: string(LLMConfig.LLM), - Data: []byte(LLMConfig.APIKey), - }) + err := s.ring.Set(keyring.Item{ //save apiKey using keychain to OS credentials + Key: string(LLMConfig.LLM), + Data: []byte(LLMConfig.APIKey), + }) if err != nil { - return errors.New("error storing credentials") - } + return errors.New("error storing credentials") + } } cfg.Default = LLMConfig.LLM @@ -117,11 +111,9 @@ func (s *StoreMethods) Save(LLMConfig LLMProvider) error { return os.WriteFile(configPath, data, 0600) } - // DefaultLLMKey returns the currently selected default LLM provider, if any. func (s *StoreMethods) DefaultLLMKey() (*LLMProvider, error) { - var cfg Config var useModel LLMProvider @@ -153,10 +145,10 @@ func (s *StoreMethods) DefaultLLMKey() (*LLMProvider, error) { for i, p := range cfg.LLMProviders { if p == defaultLLM { - useModel.LLM = cfg.LLMProviders[i] // Fetches default Model from config json - i,err := s.ring.Get(string(useModel.LLM)) //Fetches apiKey from OS credential for default model + useModel.LLM = cfg.LLMProviders[i] // Fetches default Model from config json + i, err := s.ring.Get(string(useModel.LLM)) //Fetches apiKey from OS credential for default model if err != nil { - return nil,err + return nil, err } useModel.APIKey = string(i.Data) return &useModel, nil @@ -280,8 +272,8 @@ func (s *StoreMethods) DeleteModel(Model types.LLMProvider) error { } else { err := s.ring.Remove(string(Model)) // Removes the apiKey from OS credentials if err != nil { - return err - } + return err + } return os.WriteFile(configPath, []byte("{}"), 0600) } } else { @@ -292,7 +284,7 @@ func (s *StoreMethods) DeleteModel(Model types.LLMProvider) error { newCfg.LLMProviders = append(newCfg.LLMProviders, p) } } - + err := s.ring.Remove(string(Model)) //Remove the apiKey from OS credentials if err != nil { return err @@ -338,8 +330,8 @@ func (s *StoreMethods) UpdateAPIKey(Model types.LLMProvider, APIKey string) erro updated := false for _, p := range cfg.LLMProviders { if p == Model { - err := s.ring.Set(keyring.Item{ // Update the apiKey in OS credential - Key: string(Model), + err := s.ring.Set(keyring.Item{ // Update the apiKey in OS credential + Key: string(Model), Data: []byte(APIKey), }) if err != nil { @@ -361,4 +353,3 @@ func (s *StoreMethods) UpdateAPIKey(Model types.LLMProvider, APIKey string) erro return os.WriteFile(configPath, data, 0600) } - diff --git a/cmd/commit-msg/main.go b/cmd/commit-msg/main.go index 3475c3c..3c9e550 100644 --- a/cmd/commit-msg/main.go +++ b/cmd/commit-msg/main.go @@ -9,14 +9,12 @@ import ( // main is the entry point of the commit message generator func main() { - + //Initializes the OS credential manager KeyRing, err := store.KeyringInit() - if err != nil { - log.Fatalf("Failed to initilize Keyring store: %v", err) - } + if err != nil { + log.Fatalf("Failed to initilize Keyring store: %v", err) + } cmd.StoreInit(KeyRing) //Passes StoreMethods instance to root cmd.Execute() - } - - +} diff --git a/internal/git/operations.go b/internal/git/operations.go index a8947ee..d0887d4 100644 --- a/internal/git/operations.go +++ b/internal/git/operations.go @@ -34,15 +34,15 @@ func parseGitNameStatus(line string) parseGitStatusLine { if line == "" { return parseGitStatusLine{} } - + // Git uses tabs to separate fields in --name-status output parts := strings.Split(line, "\t") if len(parts) < 2 { return parseGitStatusLine{} } - + status := parts[0] - + // Handle rename/copy status codes (e.g., "R100", "C75") if len(status) > 1 && (status[0] == 'R' || status[0] == 'C') { // For rename/copy, we expect: "R100\toldname\tnewname" or "C75\toldname\tnewname" @@ -56,7 +56,7 @@ func parseGitNameStatus(line string) parseGitStatusLine { } } } - + // Handle regular status codes (M, A, D, etc.) filename := parts[1] return parseGitStatusLine{ @@ -70,21 +70,21 @@ func processGitStatusOutput(nameStatusOutput string, returnFilenames bool) ([]st if nameStatusOutput == "" { return nil, nil } - + lines := strings.Split(strings.TrimSpace(nameStatusOutput), "\n") var filteredLines []string var nonBinaryFiles []string - + for _, line := range lines { if line == "" { continue } - + parsed := parseGitNameStatus(line) if len(parsed.filenames) == 0 { continue } - + // Check if any of the filenames are binary hasBinaryFile := false for _, filename := range parsed.filenames { @@ -93,7 +93,7 @@ func processGitStatusOutput(nameStatusOutput string, returnFilenames bool) ([]st break } } - + // If no binary files found, include this line/files if !hasBinaryFile { filteredLines = append(filteredLines, line) @@ -102,18 +102,18 @@ func processGitStatusOutput(nameStatusOutput string, returnFilenames bool) ([]st } } } - + return filteredLines, nonBinaryFiles } // filterBinaryFiles filters out binary files from git diff --name-status output func filterBinaryFiles(nameStatusOutput string) string { filteredLines, _ := processGitStatusOutput(nameStatusOutput, false) - + if len(filteredLines) == 0 { return "" } - + return strings.Join(filteredLines, "\n") } @@ -137,7 +137,7 @@ func GetChanges(config *types.RepoConfig) (string, error) { if len(output) > 0 { // Filter out binary files from the name-status output filteredOutput := filterBinaryFiles(string(output)) - + if filteredOutput != "" { changes.WriteString("Unstaged changes:\n") changes.WriteString(filteredOutput) @@ -170,7 +170,7 @@ func GetChanges(config *types.RepoConfig) (string, error) { if len(stagedOutput) > 0 { // Filter out binary files from the staged changes filteredStagedOutput := filterBinaryFiles(string(stagedOutput)) - + if filteredStagedOutput != "" { changes.WriteString("Staged changes:\n") changes.WriteString(filteredStagedOutput) @@ -204,7 +204,7 @@ func GetChanges(config *types.RepoConfig) (string, error) { // Filter out binary files from untracked files untrackedFiles := strings.Split(strings.TrimSpace(string(untrackedOutput)), "\n") var nonBinaryUntrackedFiles []string - + for _, file := range untrackedFiles { if file == "" { continue @@ -213,7 +213,7 @@ func GetChanges(config *types.RepoConfig) (string, error) { nonBinaryUntrackedFiles = append(nonBinaryUntrackedFiles, file) } } - + if len(nonBinaryUntrackedFiles) > 0 { changes.WriteString("Untracked files:\n") changes.WriteString(strings.Join(nonBinaryUntrackedFiles, "\n")) diff --git a/internal/http/client.go b/internal/http/client.go index a0ef723..5949f0b 100644 --- a/internal/http/client.go +++ b/internal/http/client.go @@ -8,11 +8,11 @@ import ( ) var ( - clientOnce sync.Once + clientOnce sync.Once sharedClient *http.Client - + ollamaClientOnce sync.Once - ollamaClient *http.Client + ollamaClient *http.Client ) // createTransport creates a shared HTTP transport with optimized settings @@ -48,4 +48,4 @@ func GetOllamaClient() *http.Client { } }) return ollamaClient -} \ No newline at end of file +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 12a7177..43fb0af 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -22,7 +22,7 @@ func IsTextFile(filename string) bool { ".txt", ".md", ".go", ".js", ".py", ".java", ".c", ".cpp", ".h", ".html", ".css", ".json", ".xml", ".yaml", ".yml", ".sh", ".bash", ".ts", ".tsx", ".jsx", ".php", ".rb", ".rs", ".dart", ".sql", ".r", - ".scala", ".kt", ".swift", ".m", ".pl", ".lua", ".vim", ".csv", + ".scala", ".kt", ".swift", ".m", ".pl", ".lua", ".vim", ".csv", ".log", ".cfg", ".conf", ".ini", ".toml", ".lock", ".gitignore", ".dockerfile", ".makefile", ".cmake", ".pro", ".pri", ".svg", } @@ -38,11 +38,11 @@ func IsTextFile(filename string) bool { if ext == "" { baseName := strings.ToLower(filepath.Base(filename)) commonTextFiles := []string{ - "readme", "dockerfile", "makefile", "rakefile", "gemfile", + "readme", "dockerfile", "makefile", "rakefile", "gemfile", "procfile", "jenkinsfile", "vagrantfile", "changelog", "authors", "contributors", "copying", "install", "news", "todo", } - + for _, textFile := range commonTextFiles { if baseName == textFile { return true