diff --git a/cmd/main.go b/cmd/main.go index 34e9a7b..59790dc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -22,6 +22,7 @@ import ( cfg "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/git" "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/ports" "github.com/thomas-vilte/matecommit/internal/providers" "github.com/thomas-vilte/matecommit/internal/services" @@ -31,6 +32,8 @@ import ( ) func main() { + logger.Initialize(false, false) + app, err := initializeApp() if err != nil { log.Fatalf("Error starting the CLI: %v", err) @@ -62,9 +65,16 @@ func initializeApp() (*cli.Command, error) { } ctx := context.Background() - gitService := git.NewGitService() + isCompletion := false + for _, arg := range os.Args { + if arg == "completion" || arg == "--generate-shell-completion" { + isCompletion = true + break + } + } + var commitAI ports.CommitSummarizer var prAI ports.PRSummarizer var issueAI ports.IssueContentGenerator @@ -73,33 +83,46 @@ func initializeApp() (*cli.Command, error) { onConfirmation := createConfirmationCallback(translations) commitAI, err = providers.NewCommitSummarizer(ctx, cfgApp, onConfirmation) - if err != nil { - log.Printf("Warning: could not create CommitSummarizer: %v", err) - log.Println("AI is not configured. You can configure it with 'mate-commit config init'") + if err != nil && !isCompletion { + logger.Warn(ctx, "could not create CommitSummarizer", + "error", err) + logger.Info(ctx, "AI is not configured. You can configure it with 'mate-commit config init'") } prAI, err = providers.NewPRSummarizer(ctx, cfgApp, onConfirmation) if err != nil { - log.Printf("Warning: could not create PRSummarizer: %v", err) + if !isCompletion { + logger.Warn(ctx, "could not create PRSummarizer", + "error", err) + } prAI = nil } issueAI, err = providers.NewIssueContentGenerator(ctx, cfgApp, onConfirmation) if err != nil { - log.Printf("Warning: could not create IssueContentGenerator: %v", err) + if !isCompletion { + logger.Warn(ctx, "could not create IssueContentGenerator", + "error", err) + } issueAI = nil } } vcsClient, err := providers.NewVCSClient(ctx, gitService, cfgApp) if err != nil { - log.Printf("Warning: could not create VCS client: %v", err) + if !isCompletion { + logger.Warn(ctx, "could not create VCS client", + "error", err) + } vcsClient = nil } ticketMgr, err := providers.NewTicketManager(ctx, cfgApp) if err != nil { - log.Printf("Warning: could not create Ticket manager: %v", err) + if !isCompletion { + logger.Warn(ctx, "could not create Ticket manager", + "error", err) + } ticketMgr = nil } @@ -175,6 +198,21 @@ func initializeApp() (*cli.Command, error) { Description: translations.GetMessage("app_description", 0, nil), Commands: commands, EnableShellCompletion: true, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + Usage: translations.GetMessage("flags_global.debug_flag", 0, nil), + }, + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Usage: translations.GetMessage("flags_global.verbose_flag", 0, nil), + }, + }, + Before: func(ctx context.Context, c *cli.Command) (context.Context, error) { + logger.Initialize(c.Bool("debug"), c.Bool("verbose")) + return ctx, nil + }, }, nil } diff --git a/internal/ai/cost_wrapper.go b/internal/ai/cost_wrapper.go index 05192cf..0ade96b 100644 --- a/internal/ai/cost_wrapper.go +++ b/internal/ai/cost_wrapper.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "time" "github.com/thomas-vilte/matecommit/internal/cache" @@ -90,9 +91,17 @@ func (w *CostAwareWrapper) WrapGenerate( contentHash := w.cache.GenerateHash(providerName + originalModel + prompt) + slog.Debug("checking cache for operation", + "command", command, + "cache_key_hash", contentHash) + if cachedData, hit, err := w.cache.Get(contentHash); err == nil && hit { var cachedResp interface{} if err := json.Unmarshal(cachedData, &cachedResp); err == nil { + slog.Info("cache hit", + "command", command, + "cache_key_hash", contentHash) + usage := &models.TokenUsage{ CacheHit: true, CostUSD: 0, @@ -103,6 +112,9 @@ func (w *CostAwareWrapper) WrapGenerate( } } + slog.Debug("cache miss, generating new content", + "command", command) + var inputTokens int tokens, err := w.provider.CountTokens(ctx, prompt) if err == nil { @@ -151,7 +163,15 @@ func (w *CostAwareWrapper) WrapGenerate( return nil, nil, err } - _ = w.cache.Set(contentHash, resp) + if err := w.cache.Set(contentHash, resp); err != nil { + slog.Warn("failed to cache response", + "command", command, + "error", err) + } else { + slog.Debug("response cached successfully", + "command", command, + "cache_key_hash", contentHash) + } if usage != nil { usage.Model = modelToUse diff --git a/internal/ai/gemini/commit_summarizer_service.go b/internal/ai/gemini/commit_summarizer_service.go index e24447d..c8b91fa 100644 --- a/internal/ai/gemini/commit_summarizer_service.go +++ b/internal/ai/gemini/commit_summarizer_service.go @@ -10,6 +10,7 @@ import ( "github.com/thomas-vilte/matecommit/internal/ai" "github.com/thomas-vilte/matecommit/internal/config" domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ports" "google.golang.org/genai" @@ -89,18 +90,41 @@ func NewGeminiCommitSummarizer(ctx context.Context, cfg *config.Config, onConfir } func (s *GeminiCommitSummarizer) defaultGenerate(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + log := logger.FromContext(ctx) + + log.Debug("calling gemini API", + "model", mName, + "prompt_length", len(p)) + genConfig := GetGenerateConfig(mName, "application/json") resp, err := s.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) if err != nil { + log.Error("gemini API call failed", + "error", err, + "model", mName) return nil, nil, err } usage := extractUsage(resp) + + log.Debug("gemini API response received", + "input_tokens", usage.InputTokens, + "output_tokens", usage.OutputTokens, + "candidates", len(resp.Candidates)) + return resp, usage, nil } func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info models.CommitInfo, count int) ([]models.CommitSuggestion, error) { + log := logger.FromContext(ctx) + + log.Info("generating commit suggestions via gemini", + "count", count, + "files", len(info.Files), + "has_issue_info", info.IssueInfo != nil, + "has_ticket_info", info.TicketInfo != nil) + if count <= 0 { return nil, domainErrors.NewAppError(domainErrors.TypeInternal, "invalid suggestion count", nil) } @@ -111,8 +135,14 @@ func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info m prompt := s.generatePrompt(s.config.Language, info, count) + log.Debug("prompt generated", + "prompt_length", len(prompt), + "language", s.config.Language) + resp, usage, err := s.wrapper.WrapGenerate(ctx, "suggest-commits", prompt, s.generateFn) if err != nil { + log.Error("failed to generate suggestions", + "error", err) return nil, domainErrors.NewAppError(domainErrors.TypeAI, "error generating content", err) } @@ -137,14 +167,21 @@ func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info m return nil, domainErrors.NewAppError(domainErrors.TypeAI, fmt.Sprintf("error parsing AI JSON response (length: %d): %s", respLen, preview), err) } if len(suggestions) == 0 { + log.Warn("AI generated no suggestions") return nil, domainErrors.NewAppError(domainErrors.TypeAI, "AI generated no suggestions", nil) } for i := range suggestions { suggestions[i].Usage = usage } if info.IssueInfo != nil && info.IssueInfo.Number > 0 { + log.Debug("ensuring issue reference in suggestions", + "issue_number", info.IssueInfo.Number) suggestions = s.ensureIssueReference(suggestions, info.IssueInfo.Number) } + + log.Info("commit suggestions generated successfully", + "count", len(suggestions)) + return suggestions, nil } @@ -157,6 +194,7 @@ func (s *GeminiCommitSummarizer) parseSuggestionsJSON(responseText string) ([]mo var jsonSuggestions []CommitSuggestionJSON if err := json.Unmarshal([]byte(responseText), &jsonSuggestions); err != nil { + // Log at default level (no context available here) return nil, fmt.Errorf("error parsing JSON: %w", err) } diff --git a/internal/ai/gemini/issue_content_generator.go b/internal/ai/gemini/issue_content_generator.go index 70a5f69..5118269 100644 --- a/internal/ai/gemini/issue_content_generator.go +++ b/internal/ai/gemini/issue_content_generator.go @@ -6,11 +6,12 @@ import ( "fmt" "strings" + "github.com/thomas-vilte/matecommit/internal/ai" "github.com/thomas-vilte/matecommit/internal/config" domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ports" - "github.com/thomas-vilte/matecommit/internal/ai" "google.golang.org/genai" ) @@ -79,10 +80,23 @@ func (s *GeminiIssueContentGenerator) defaultGenerate(ctx context.Context, mName // GenerateIssueContent generates issue content using Gemini AI. func (s *GeminiIssueContentGenerator) GenerateIssueContent(ctx context.Context, request models.IssueGenerationRequest) (*models.IssueGenerationResult, error) { + log := logger.FromContext(ctx) + + log.Info("generating issue content via gemini", + "has_diff", request.Diff != "", + "has_description", request.Description != "", + "has_hint", request.Hint != "", + "files_count", len(request.ChangedFiles)) + prompt := s.buildIssuePrompt(request) + log.Debug("calling gemini API for issue content", + "prompt_length", len(prompt)) + resp, usage, err := s.wrapper.WrapGenerate(ctx, "generate-issue", prompt, s.generateFn) if err != nil { + log.Error("failed to generate issue content", + "error", err) return nil, domainErrors.NewAppError(domainErrors.TypeAI, "error generating issue content", err) } @@ -94,16 +108,26 @@ func (s *GeminiIssueContentGenerator) GenerateIssueContent(ctx context.Context, } if responseText == "" { + log.Error("empty response from gemini AI") return nil, domainErrors.NewAppError(domainErrors.TypeAI, "empty response from AI", nil) } + log.Debug("gemini response received", + "response_length", len(responseText)) + result, err := s.parseIssueResponse(responseText) if err != nil { + log.Error("failed to parse issue response", + "error", err) return nil, domainErrors.NewAppError(domainErrors.TypeAI, "error parsing AI response", err) } result.Usage = usage + log.Info("issue content generated successfully via gemini", + "title", result.Title, + "labels_count", len(result.Labels)) + return result, nil } diff --git a/internal/ai/gemini/pull_requests_summarizer_service.go b/internal/ai/gemini/pull_requests_summarizer_service.go index 284a7e2..ae4703c 100644 --- a/internal/ai/gemini/pull_requests_summarizer_service.go +++ b/internal/ai/gemini/pull_requests_summarizer_service.go @@ -6,11 +6,12 @@ import ( "fmt" "strings" + "github.com/thomas-vilte/matecommit/internal/ai" "github.com/thomas-vilte/matecommit/internal/config" domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ports" - "github.com/thomas-vilte/matecommit/internal/ai" "google.golang.org/genai" ) @@ -82,10 +83,20 @@ func (gps *GeminiPRSummarizer) defaultGenerate(ctx context.Context, mName string } func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prContent string) (models.PRSummary, error) { + log := logger.FromContext(ctx) + + log.Info("generating PR summary via gemini", + "content_length", len(prContent)) + prompt := gps.generatePRPrompt(prContent) + log.Debug("calling gemini API for PR summary", + "prompt_length", len(prompt)) + resp, usage, err := gps.wrapper.WrapGenerate(ctx, "summarize-pr", prompt, gps.generateFn) if err != nil { + log.Error("failed to generate PR summary", + "error", err) return models.PRSummary{}, domainErrors.NewAppError(domainErrors.TypeAI, "error generating PR summary", err) } @@ -109,8 +120,14 @@ func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prContent if respLen > 500 { preview = responseText[:500] + "..." } + log.Warn("AI generated no PR title", + "response_length", respLen) return models.PRSummary{}, domainErrors.NewAppError(domainErrors.TypeAI, fmt.Sprintf("AI generated no PR title (length: %d): %s", respLen, preview), nil) } + + log.Info("PR summary generated successfully via gemini", + "labels_count", len(jsonSummary.Labels)) + return models.PRSummary{ Title: jsonSummary.Title, Body: jsonSummary.Body, diff --git a/internal/ai/gemini/release_generator.go b/internal/ai/gemini/release_generator.go index 6d12169..6bed69d 100644 --- a/internal/ai/gemini/release_generator.go +++ b/internal/ai/gemini/release_generator.go @@ -6,11 +6,12 @@ import ( "fmt" "strings" + "github.com/thomas-vilte/matecommit/internal/ai" "github.com/thomas-vilte/matecommit/internal/config" domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ports" - "github.com/thomas-vilte/matecommit/internal/ai" "google.golang.org/genai" ) @@ -90,10 +91,25 @@ func (g *ReleaseNotesGenerator) defaultGenerate(ctx context.Context, mName strin } func (g *ReleaseNotesGenerator) GenerateNotes(ctx context.Context, release *models.Release) (*models.ReleaseNotes, error) { + log := logger.FromContext(ctx) + + log.Info("generating release notes via gemini", + "version", release.Version, + "previous_version", release.PreviousVersion, + "features_count", len(release.Features), + "bugfixes_count", len(release.BugFixes), + "breaking_count", len(release.Breaking)) + prompt := g.buildPrompt(release) + log.Debug("calling gemini API for release notes", + "prompt_length", len(prompt)) + resp, usage, err := g.wrapper.WrapGenerate(ctx, "generate-release", prompt, g.generateFn) if err != nil { + log.Error("failed to generate release notes", + "error", err, + "version", release.Version) return nil, domainErrors.NewAppError(domainErrors.TypeAI, "error generating release notes", err) } @@ -105,16 +121,26 @@ func (g *ReleaseNotesGenerator) GenerateNotes(ctx context.Context, release *mode } if responseText == "" { + log.Error("empty response from gemini AI") return nil, domainErrors.NewAppError(domainErrors.TypeAI, "empty response from AI", nil) } + log.Debug("gemini response received", + "response_length", len(responseText)) + notes, err := g.parseJSONResponse(responseText, release) if err != nil { + log.Error("failed to parse release notes response", + "error", err) return nil, domainErrors.NewAppError(domainErrors.TypeAI, "error parsing AI JSON response", err) } notes.Usage = usage + log.Info("release notes generated successfully via gemini", + "title", notes.Title, + "highlights_count", len(notes.Highlights)) + return notes, nil } diff --git a/internal/commands/handler/suggestions.go b/internal/commands/handler/suggestions.go index 764ce25..06764f0 100644 --- a/internal/commands/handler/suggestions.go +++ b/internal/commands/handler/suggestions.go @@ -5,9 +5,11 @@ import ( "fmt" "os" "strings" + "time" "github.com/fatih/color" "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ports" "github.com/thomas-vilte/matecommit/internal/ui" @@ -39,8 +41,19 @@ func NewSuggestionHandler(gitSvc gitService, vcs ports.VCSClient, t *i18n.Transl } func (h *SuggestionHandler) HandleSuggestions(ctx context.Context, suggestions []models.CommitSuggestion) error { + log := logger.FromContext(ctx) + log.Info("handling commit suggestions", "count", len(suggestions)) + h.displaySuggestions(suggestions) - return h.handleCommitSelection(ctx, suggestions) + + err := h.handleCommitSelection(ctx, suggestions) + if err != nil { + logger.Error(ctx, "failed to handle commit selection", err) + return err + } + + log.Info("suggestions handled successfully") + return nil } func (h *SuggestionHandler) displaySuggestions(suggestions []models.CommitSuggestion) { @@ -186,34 +199,45 @@ func (h *SuggestionHandler) getCriteriaStatusText(status models.CriteriaStatus) } func (h *SuggestionHandler) handleCommitSelection(ctx context.Context, suggestions []models.CommitSuggestion) error { + log := logger.FromContext(ctx) var selection int prompt := color.New(color.FgCyan, color.Bold).Sprint(h.t.GetMessage("ui_selection.select_option", 0, nil)) fmt.Print(prompt + " ") if _, err := fmt.Scan(&selection); err != nil { + logger.Error(ctx, "failed to read user selection", err) msg := h.t.GetMessage("commit.error_reading_selection", 0, struct{ Error error }{err}) ui.PrintError(os.Stdout, msg) return fmt.Errorf("%s", msg) } + log.Debug("user selection received", "selection", selection) + if selection == 0 { + log.Info("user cancelled operation") ui.PrintWarning(h.t.GetMessage("commit.operation_canceled", 0, nil)) return nil } if selection < 1 || selection > len(suggestions) { + log.Warn("invalid selection", "selection", selection, "max", len(suggestions)) msg := h.t.GetMessage("commit.invalid_selection", 0, struct{ Number int }{len(suggestions)}) ui.PrintError(os.Stdout, msg) return fmt.Errorf("%s", msg) } + log.Info("processing selected commit", "selection", selection) return h.processCommit(ctx, suggestions[selection-1], h.gitService) } func (h *SuggestionHandler) processCommit(ctx context.Context, suggestion models.CommitSuggestion, gitSvc gitService) error { + log := logger.FromContext(ctx) + start := time.Now() + commitTitle := strings.TrimSpace(strings.TrimPrefix(suggestion.CommitTitle, "Commit: ")) + log.Info("processing commit", "title", commitTitle, "files_count", len(suggestion.Files)) fmt.Println() ui.PrintInfo(h.t.GetMessage("ui_preview.commit_selected", 0, struct{ Title string }{commitTitle})) @@ -229,6 +253,7 @@ func (h *SuggestionHandler) processCommit(ctx context.Context, suggestion models } if ui.AskConfirmation(h.t.GetMessage("ui_preview.ask_show_diff", 0, nil)) { + log.Debug("showing diff to user") fmt.Println() if err := ui.ShowDiff(suggestion.Files); err != nil { ui.PrintWarning(h.t.GetMessage("ui_preview.error_showing_diff", 0, struct{ Error error }{err})) @@ -237,26 +262,32 @@ func (h *SuggestionHandler) processCommit(ctx context.Context, suggestion models finalCommitMessage := commitTitle if ui.AskConfirmation(h.t.GetMessage("ui_preview.ask_edit_message", 0, nil)) { + log.Debug("user editing commit message") editorError := h.t.GetMessage("ui_preview.editor_error", 0, nil) editedMessage, err := ui.EditCommitMessage(commitTitle, editorError) if err != nil { + logger.Error(ctx, "failed to edit commit message", err) ui.PrintError(os.Stdout, h.t.GetMessage("ui_preview.error_editing_message", 0, struct{ Error error }{err})) return err } finalCommitMessage = editedMessage + log.Debug("commit message edited", "new_message", finalCommitMessage) ui.PrintSuccess(os.Stdout, h.t.GetMessage("ui_preview.message_updated", 0, nil)) } if !ui.AskConfirmation(h.t.GetMessage("ui_preview.ask_confirm_commit", 0, nil)) { + log.Info("user cancelled commit") ui.PrintWarning(h.t.GetMessage("ui_preview.commit_cancelled", 0, nil)) return nil } + log.Debug("staging files", "count", len(suggestion.Files)) spinner := ui.NewSmartSpinner(h.t.GetMessage("ui.adding_to_staging", 0, nil)) spinner.Start() for _, file := range suggestion.Files { if err := gitSvc.AddFileToStaging(ctx, file); err != nil { + logger.Error(ctx, "failed to stage file", err, "file", file) spinner.Error(h.t.GetMessage("commit.error_add_file_staging", 0, struct { File string Error error @@ -265,16 +296,24 @@ func (h *SuggestionHandler) processCommit(ctx context.Context, suggestion models } } + log.Debug("files staged successfully", "count", len(suggestion.Files)) spinner.Success(h.t.GetMessage("ui.files_added_to_staging", 0, struct{ Count int }{len(suggestion.Files)})) + log.Debug("creating commit", "message", finalCommitMessage) spinner = ui.NewSmartSpinner(h.t.GetMessage("ui.creating_commit", 0, nil)) spinner.Start() if err := gitSvc.CreateCommit(ctx, finalCommitMessage); err != nil { + logger.Error(ctx, "failed to create commit", err, "message", finalCommitMessage) spinner.Error(h.t.GetMessage("commit.error_creating_commit", 0, struct{ Error error }{err})) return fmt.Errorf("error creating commit: %w", err) } + log.Info("commit created successfully", + "message", finalCommitMessage, + "files_count", len(suggestion.Files), + "duration_ms", time.Since(start).Milliseconds()) + spinner.Success(h.t.GetMessage("ui.commit_created_successfully", 0, nil)) fmt.Printf("\n %s\n\n", finalCommitMessage) diff --git a/internal/commands/issues/issues.go b/internal/commands/issues/issues.go index 3689e25..e7cf922 100644 --- a/internal/commands/issues/issues.go +++ b/internal/commands/issues/issues.go @@ -7,10 +7,12 @@ import ( "os" "os/exec" "strings" + "time" "github.com/thomas-vilte/matecommit/internal/commands/completion_helper" "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ui" "github.com/urfave/cli/v3" @@ -30,9 +32,9 @@ type IssueGeneratorService interface { // IssueTemplateService is a minimal interface for testing purposes type IssueTemplateService interface { - InitializeTemplates(force bool) error - GetTemplatesDir() (string, error) - ListTemplates() ([]models.TemplateMetadata, error) + InitializeTemplates(ctx context.Context, force bool) error + GetTemplatesDir(ctx context.Context) (string, error) + ListTemplates(ctx context.Context) ([]models.TemplateMetadata, error) } type IssueServiceProvider func(ctx context.Context) (IssueGeneratorService, error) @@ -129,6 +131,9 @@ func (f *IssuesCommandFactory) createGenerateFlags(t *i18n.Translations) []cli.F func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *config.Config) cli.ActionFunc { return func(ctx context.Context, command *cli.Command) error { + log := logger.FromContext(ctx) + start := time.Now() + fromDiff := command.Bool("from-diff") fromPR := command.Int("from-pr") description := command.String("description") @@ -139,6 +144,17 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c checkoutBranch := command.Bool("checkout") templateName := command.String("template") + log.Info("executing issue generate command", + "from_diff", fromDiff, + "from_pr", fromPR, + "has_description", description != "", + "has_hint", hint != "", + "no_labels", noLabels, + "dry_run", dryRun, + "assign_me", assignMe, + "checkout_branch", checkoutBranch, + "template", templateName) + sourcesCount := 0 if fromDiff { sourcesCount++ @@ -151,11 +167,15 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c } if sourcesCount == 0 { + log.Error("no input source provided", + "duration_ms", time.Since(start).Milliseconds()) ui.PrintError(os.Stdout, t.GetMessage("issue.error_no_input", 0, nil)) return fmt.Errorf("%s", t.GetMessage("issue.error_no_input", 0, nil)) } if sourcesCount > 1 { + log.Error("multiple input sources provided", + "duration_ms", time.Since(start).Milliseconds()) ui.PrintError(os.Stdout, t.GetMessage("issue.error_multiple_sources", 0, nil)) return fmt.Errorf("%s", t.GetMessage("issue.error_multiple_sources", 0, nil)) } @@ -164,6 +184,9 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c issueService, err := f.issueServiceProvider(ctx) if err != nil { + log.Error("failed to create issue service", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) ui.PrintError(os.Stdout, fmt.Sprintf("%s: %v", t.GetMessage("issue.error_generating", 0, nil), err)) return err } @@ -193,10 +216,19 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c spinner.Stop() if err != nil { + log.Error("failed to generate issue", + "error", err, + "from_diff", fromDiff, + "from_pr", fromPR, + "duration_ms", time.Since(start).Milliseconds()) ui.HandleAppError(err, t) return err } + log.Debug("issue generated", + "title", result.Title, + "labels_count", len(result.Labels)) + f.printPreview(result, t, cfg) ui.PrintTokenUsage(result.Usage, t) @@ -233,10 +265,19 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c spinner.Stop() if err != nil { + log.Error("failed to create issue", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) ui.HandleAppError(err, t) return err } + log.Info("issue created successfully", + "issue_number", issue.Number, + "issue_url", issue.URL, + "assignees_count", len(assignees), + "duration_ms", time.Since(start).Milliseconds()) + ui.PrintSuccess(os.Stdout, t.GetMessage("issue.created_successfully", 0, struct { Number int URL string @@ -356,15 +397,28 @@ func (f *IssuesCommandFactory) newLinkCommand(t *i18n.Translations, cfg *config. // createLinkAction creates the action to link a PR to an issue. func (f *IssuesCommandFactory) createLinkAction(t *i18n.Translations, _ *config.Config) cli.ActionFunc { return func(ctx context.Context, command *cli.Command) error { + log := logger.FromContext(ctx) + start := time.Now() + prNumber := command.Int("pr") issueNumber := command.Int("issue") + log.Info("executing issue link command", + "pr_number", prNumber, + "issue_number", issueNumber) + if prNumber <= 0 { + log.Error("invalid PR number", + "pr_number", prNumber, + "duration_ms", time.Since(start).Milliseconds()) ui.PrintError(os.Stdout, t.GetMessage("issue.error_invalid_pr", 0, nil)) return fmt.Errorf("invalid PR number") } if issueNumber <= 0 { + log.Error("invalid issue number", + "issue_number", issueNumber, + "duration_ms", time.Since(start).Milliseconds()) ui.PrintError(os.Stdout, t.GetMessage("issue.error_invalid_issue", 0, nil)) return fmt.Errorf("invalid issue number") } @@ -373,6 +427,9 @@ func (f *IssuesCommandFactory) createLinkAction(t *i18n.Translations, _ *config. issueService, err := f.issueServiceProvider(ctx) if err != nil { + log.Error("failed to create issue service", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) ui.HandleAppError(err, t) return err } @@ -387,10 +444,20 @@ func (f *IssuesCommandFactory) createLinkAction(t *i18n.Translations, _ *config. spinner.Stop() if err != nil { + log.Error("failed to link issue to PR", + "error", err, + "pr_number", prNumber, + "issue_number", issueNumber, + "duration_ms", time.Since(start).Milliseconds()) ui.PrintError(os.Stdout, fmt.Sprintf("%s: %v", t.GetMessage("issue.error_linking", 0, nil), err)) return err } + log.Info("issue linked to PR successfully", + "pr_number", prNumber, + "issue_number", issueNumber, + "duration_ms", time.Since(start).Milliseconds()) + ui.PrintSuccess(os.Stdout, t.GetMessage("issue.link_success", 0, struct { PR int Issue int diff --git a/internal/commands/issues/issues_test.go b/internal/commands/issues/issues_test.go index 9e129b8..9f47599 100644 --- a/internal/commands/issues/issues_test.go +++ b/internal/commands/issues/issues_test.go @@ -5,12 +5,12 @@ import ( "os" "testing" - "github.com/thomas-vilte/matecommit/internal/config" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/config" + "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/models" "github.com/urfave/cli/v3" ) diff --git a/internal/commands/issues/mocks.go b/internal/commands/issues/mocks.go index ac1c8bf..41c6ef6 100644 --- a/internal/commands/issues/mocks.go +++ b/internal/commands/issues/mocks.go @@ -3,8 +3,8 @@ package issues import ( "context" - "github.com/thomas-vilte/matecommit/internal/models" "github.com/stretchr/testify/mock" + "github.com/thomas-vilte/matecommit/internal/models" ) type MockIssueGeneratorService struct { @@ -70,37 +70,37 @@ type MockIssueTemplateService struct { mock.Mock } -func (m *MockIssueTemplateService) GetTemplatesDir() (string, error) { - args := m.Called() +func (m *MockIssueTemplateService) GetTemplatesDir(ctx context.Context) (string, error) { + args := m.Called(ctx) return args.String(0), args.Error(1) } -func (m *MockIssueTemplateService) ListTemplates() ([]models.TemplateMetadata, error) { - args := m.Called() +func (m *MockIssueTemplateService) ListTemplates(ctx context.Context) ([]models.TemplateMetadata, error) { + args := m.Called(ctx) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]models.TemplateMetadata), args.Error(1) } -func (m *MockIssueTemplateService) LoadTemplate(filePath string) (*models.IssueTemplate, error) { - args := m.Called(filePath) +func (m *MockIssueTemplateService) LoadTemplate(ctx context.Context, filePath string) (*models.IssueTemplate, error) { + args := m.Called(ctx, filePath) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*models.IssueTemplate), args.Error(1) } -func (m *MockIssueTemplateService) GetTemplateByName(name string) (*models.IssueTemplate, error) { - args := m.Called(name) +func (m *MockIssueTemplateService) GetTemplateByName(ctx context.Context, name string) (*models.IssueTemplate, error) { + args := m.Called(ctx, name) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*models.IssueTemplate), args.Error(1) } -func (m *MockIssueTemplateService) InitializeTemplates(force bool) error { - args := m.Called(force) +func (m *MockIssueTemplateService) InitializeTemplates(ctx context.Context, force bool) error { + args := m.Called(ctx, force) return args.Error(0) } diff --git a/internal/commands/issues/templates.go b/internal/commands/issues/templates.go index d3eece2..4052d8e 100644 --- a/internal/commands/issues/templates.go +++ b/internal/commands/issues/templates.go @@ -36,12 +36,12 @@ func (f *IssuesCommandFactory) newTemplateCommand(t *i18n.Translations, _ *confi ui.PrintSectionBanner(t.GetMessage("issue.template_init_banner", 0, nil)) ui.PrintInfo(t.GetMessage("issue.template_init_info", 0, nil)) - if err := templateService.InitializeTemplates(force); err != nil { + if err := templateService.InitializeTemplates(ctx, force); err != nil { ui.PrintError(os.Stdout, fmt.Sprintf("%s: %v", t.GetMessage("issue.template_init_error", 0, nil), err)) return err } - templatesDir, _ := templateService.GetTemplatesDir() + templatesDir, _ := templateService.GetTemplatesDir(ctx) ui.PrintSuccess(os.Stdout, t.GetMessage("issue.template_init_success", 0, struct{ Dir string }{templatesDir})) return nil @@ -52,7 +52,7 @@ func (f *IssuesCommandFactory) newTemplateCommand(t *i18n.Translations, _ *confi Aliases: []string{"ls", "l"}, Usage: t.GetMessage("issue.template_list_usage", 0, nil), Action: func(ctx context.Context, cmd *cli.Command) error { - templates, err := templateService.ListTemplates() + templates, err := templateService.ListTemplates(ctx) if err != nil { ui.PrintError(os.Stdout, fmt.Sprintf("%s: %v", t.GetMessage("issue.template_list_error", 0, nil), err)) return err diff --git a/internal/commands/issues/templates_test.go b/internal/commands/issues/templates_test.go index c17142a..f35b701 100644 --- a/internal/commands/issues/templates_test.go +++ b/internal/commands/issues/templates_test.go @@ -4,8 +4,9 @@ import ( "context" "testing" - "github.com/thomas-vilte/matecommit/internal/models" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/thomas-vilte/matecommit/internal/models" "github.com/urfave/cli/v3" ) @@ -15,8 +16,8 @@ func TestIssueTemplateAction(t *testing.T) { factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) - mockTemp.On("InitializeTemplates", false).Return(nil) - mockTemp.On("GetTemplatesDir").Return("/path/to/templates", nil) + mockTemp.On("InitializeTemplates", mock.Anything, false).Return(nil) + mockTemp.On("GetTemplatesDir", mock.Anything).Return("/path/to/templates", nil) app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} err := app.Run(context.Background(), []string{"test", "issue", "template", "init"}) @@ -30,8 +31,8 @@ func TestIssueTemplateAction(t *testing.T) { factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) - mockTemp.On("InitializeTemplates", true).Return(nil) - mockTemp.On("GetTemplatesDir").Return("/path/to/templates", nil) + mockTemp.On("InitializeTemplates", mock.Anything, true).Return(nil) + mockTemp.On("GetTemplatesDir", mock.Anything).Return("/path/to/templates", nil) app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} err := app.Run(context.Background(), []string{"test", "issue", "template", "init", "--force"}) @@ -49,7 +50,7 @@ func TestIssueTemplateAction(t *testing.T) { {Name: "Bug", About: "Bug report", FilePath: "bug.md"}, {Name: "Feature", About: "Feature request", FilePath: "feat.md"}, } - mockTemp.On("ListTemplates").Return(templates, nil) + mockTemp.On("ListTemplates", mock.Anything).Return(templates, nil) app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} err := app.Run(context.Background(), []string{"test", "issue", "template", "list"}) @@ -63,7 +64,7 @@ func TestIssueTemplateAction(t *testing.T) { factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) - mockTemp.On("ListTemplates").Return([]models.TemplateMetadata{}, nil) + mockTemp.On("ListTemplates", mock.Anything).Return([]models.TemplateMetadata{}, nil) app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} err := app.Run(context.Background(), []string{"test", "issue", "template", "list"}) diff --git a/internal/commands/pull_requests/summarize.go b/internal/commands/pull_requests/summarize.go index 8752ce7..4452f1c 100644 --- a/internal/commands/pull_requests/summarize.go +++ b/internal/commands/pull_requests/summarize.go @@ -3,10 +3,12 @@ package pull_requests import ( "context" "fmt" + "time" "github.com/thomas-vilte/matecommit/internal/commands/completion_helper" cfg "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ui" "github.com/urfave/cli/v3" @@ -45,12 +47,25 @@ func (c *SummarizeCommand) CreateCommand(t *i18n.Translations, _ *cfg.Config) *c }, ShellComplete: completion_helper.DefaultFlagComplete, Action: func(ctx context.Context, cmd *cli.Command) error { + log := logger.FromContext(ctx) + start := time.Now() + + prNumber := cmd.Int("pr-number") + + log.Info("executing summarize-pr command", + "pr_number", prNumber) + prService, err := c.prProvider(ctx) if err != nil { + log.Error("failed to create PR service", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) return fmt.Errorf(t.GetMessage("error.pr_service_creation_error", 0, nil)+": %w", err) } - prNumber := cmd.Int("pr-number") + if prNumber == 0 { + log.Error("PR number is required", + "duration_ms", time.Since(start).Milliseconds()) return fmt.Errorf("%s", t.GetMessage("error.pr_number_required", 0, nil)) } @@ -76,11 +91,21 @@ func (c *SummarizeCommand) CreateCommand(t *i18n.Translations, _ *cfg.Config) *c } }) if err != nil { + log.Error("failed to summarize PR", + "error", err, + "pr_number", prNumber, + "duration_ms", time.Since(start).Milliseconds()) spinner.Error(t.GetMessage("ui.error_generating_pr_summary", 0, nil)) ui.HandleAppError(err, t) return fmt.Errorf(t.GetMessage("error.pr_summary_error", 0, nil)+": %w", err) } + log.Info("PR summarized successfully", + "pr_number", prNumber, + "title", summary.Title, + "labels_count", len(summary.Labels), + "duration_ms", time.Since(start).Milliseconds()) + spinner.Success(t.GetMessage("ui.pr_updated_successfully", 0, struct { Number int Title string diff --git a/internal/commands/release/create.go b/internal/commands/release/create.go index 50dec0c..4609d3d 100644 --- a/internal/commands/release/create.go +++ b/internal/commands/release/create.go @@ -6,10 +6,12 @@ import ( "fmt" "os" "strings" + "time" "github.com/thomas-vilte/matecommit/internal/commands/completion_helper" cfg "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/ui" "github.com/urfave/cli/v3" ) @@ -63,28 +65,62 @@ func (r *ReleaseCommandFactory) newCreateCommand(t *i18n.Translations) *cli.Comm func createReleaseAction(releaseSvc releaseService, trans *i18n.Translations, reader *bufio.Reader, config *cfg.Config) cli.ActionFunc { return func(ctx context.Context, cmd *cli.Command) error { + log := logger.FromContext(ctx) + start := time.Now() + + autoConfirm := cmd.Bool("auto") + version := cmd.String("version") + publish := cmd.Bool("publish") + draft := cmd.Bool("draft") + changelog := cmd.Bool("changelog") + buildBinaries := cmd.Bool("build-binaries") + + log.Info("executing release create command", + "auto_confirm", autoConfirm, + "version", version, + "publish", publish, + "draft", draft, + "changelog", changelog, + "build_binaries", buildBinaries) + fmt.Println(trans.GetMessage("release.creating", 0, nil)) fmt.Println() release, err := releaseSvc.AnalyzeNextRelease(ctx) if err != nil { + log.Error("failed to analyze next release", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) return fmt.Errorf("%s", trans.GetMessage("release.error_analyzing", 0, struct{ Error string }{err.Error()})) } + log.Debug("release analyzed", + "version", release.Version, + "previous_version", release.PreviousVersion, + "version_bump", release.VersionBump) + if version := cmd.String("version"); version != "" { release.Version = version } if err := releaseSvc.EnrichReleaseContext(ctx, release); err != nil { + log.Warn("failed to enrich release context", "error", err) fmt.Printf("⚠️ %s\n", trans.GetMessage("release.warning_enrich_context", 0, struct{ Error string }{err.Error()})) } notes, err := releaseSvc.GenerateReleaseNotes(ctx, release) if err != nil { + log.Error("failed to generate release notes", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) ui.HandleAppError(err, trans) return fmt.Errorf("%s", trans.GetMessage("release.error_generating_notes", 0, struct{ Error string }{err.Error()})) } + log.Debug("release notes generated", + "title", notes.Title, + "highlights_count", len(notes.Highlights)) + updateChangelog := cmd.Bool("changelog") if config != nil && config.UpdateChangelog { updateChangelog = true @@ -155,9 +191,16 @@ func createReleaseAction(releaseSvc releaseService, trans *i18n.Translations, re message := fmt.Sprintf("%s\n\n%s", notes.Title, notes.Summary) err = releaseSvc.CreateTag(ctx, release.Version, message) if err != nil { + log.Error("failed to create tag", + "error", err, + "version", release.Version, + "duration_ms", time.Since(start).Milliseconds()) return fmt.Errorf("%s", trans.GetMessage("release.error_creating_tag", 0, struct{ Error string }{err.Error()})) } + log.Info("tag created successfully", + "version", release.Version) + fmt.Println(trans.GetMessage("release.tag_created", 0, struct{ Version string }{release.Version})) if cmd.Bool("publish") { @@ -167,8 +210,15 @@ func createReleaseAction(releaseSvc releaseService, trans *i18n.Translations, re buildBinaries := cmd.Bool("build-binaries") err := releaseSvc.PublishRelease(ctx, release, notes, cmd.Bool("draft"), buildBinaries) if err != nil { + log.Error("failed to publish release", + "error", err, + "version", release.Version, + "duration_ms", time.Since(start).Milliseconds()) return fmt.Errorf("%s", trans.GetMessage("release.error_publishing_release", 0, struct{ Error string }{err.Error()})) } + log.Info("release published successfully", + "version", release.Version, + "draft", cmd.Bool("draft")) fmt.Println(trans.GetMessage("release.release_published", 0, nil)) } else { fmt.Println() @@ -183,6 +233,10 @@ func createReleaseAction(releaseSvc releaseService, trans *i18n.Translations, re ui.PrintTokenUsage(notes.Usage, trans) } + log.Info("release create command completed successfully", + "version", release.Version, + "duration_ms", time.Since(start).Milliseconds()) + fmt.Println() return nil diff --git a/internal/commands/release/generate.go b/internal/commands/release/generate.go index d9657e0..5c76247 100644 --- a/internal/commands/release/generate.go +++ b/internal/commands/release/generate.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "os" + "time" "github.com/thomas-vilte/matecommit/internal/commands/completion_helper" "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/ui" "github.com/urfave/cli/v3" ) @@ -37,33 +39,62 @@ func (r *ReleaseCommandFactory) newGenerateCommand(trans *i18n.Translations) *cl func generateReleaseAction(releaseSvc releaseService, trans *i18n.Translations) cli.ActionFunc { return func(ctx context.Context, cmd *cli.Command) error { + log := logger.FromContext(ctx) + start := time.Now() + + outputFile := cmd.String("output") + + log.Info("executing release generate command", + "output_file", outputFile) + fmt.Println(trans.GetMessage("release.generating", 0, nil)) fmt.Println() release, err := releaseSvc.AnalyzeNextRelease(ctx) if err != nil { + log.Error("failed to analyze next release", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) ui.HandleAppError(err, trans) return fmt.Errorf("%s", trans.GetMessage("release.error_analyzing", 0, struct{ Error string }{err.Error()})) } + log.Debug("release analyzed", + "version", release.Version) + if err := releaseSvc.EnrichReleaseContext(ctx, release); err != nil { + log.Warn("failed to enrich release context", "error", err) fmt.Printf("⚠️ %s\n", trans.GetMessage("release.warning_enrich_context", 0, struct{ Error string }{err.Error()})) } notes, err := releaseSvc.GenerateReleaseNotes(ctx, release) if err != nil { + log.Error("failed to generate release notes", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) ui.HandleAppError(err, trans) return fmt.Errorf("%s", trans.GetMessage("release.error_generating_notes", 0, struct{ Error string }{err.Error()})) } + log.Debug("release notes generated", + "title", notes.Title) + content := FormatReleaseMarkdown(release, notes, trans) - outputFile := cmd.String("output") err = os.WriteFile(outputFile, []byte(content), 0644) if err != nil { + log.Error("failed to write release notes file", + "error", err, + "output_file", outputFile, + "duration_ms", time.Since(start).Milliseconds()) return fmt.Errorf("%s", trans.GetMessage("release.error_writing_file", 0, struct{ Error string }{err.Error()})) } + log.Info("release notes file written successfully", + "output_file", outputFile, + "version", release.Version, + "content_size", len(content)) + fmt.Println(trans.GetMessage("release.notes_saved", 0, struct{ File string }{outputFile})) fmt.Println(trans.GetMessage("release.version_label", 0, struct{ Version string }{release.Version})) @@ -72,6 +103,9 @@ func generateReleaseAction(releaseSvc releaseService, trans *i18n.Translations) ui.PrintTokenUsage(notes.Usage, trans) } + log.Info("release generate command completed successfully", + "duration_ms", time.Since(start).Milliseconds()) + fmt.Println() return nil diff --git a/internal/commands/release/preview.go b/internal/commands/release/preview.go index 6792bd2..a08da8d 100644 --- a/internal/commands/release/preview.go +++ b/internal/commands/release/preview.go @@ -3,9 +3,11 @@ package release import ( "context" "fmt" + "time" "github.com/thomas-vilte/matecommit/internal/commands/completion_helper" "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/ui" "github.com/urfave/cli/v3" ) @@ -28,14 +30,29 @@ func (r *ReleaseCommandFactory) newPreviewCommand(trans *i18n.Translations) *cli func previewReleaseAction(releaseSvc releaseService, trans *i18n.Translations) cli.ActionFunc { return func(ctx context.Context, cmd *cli.Command) error { + log := logger.FromContext(ctx) + start := time.Now() + + log.Info("executing release preview command") + fmt.Println(trans.GetMessage("release.analyzing", 0, nil)) fmt.Println() release, err := releaseSvc.AnalyzeNextRelease(ctx) if err != nil { + log.Error("failed to analyze next release", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) return fmt.Errorf("%s", trans.GetMessage("release.error_analyzing", 0, struct{ Error string }{err.Error()})) } + log.Debug("release analyzed", + "version", release.Version, + "previous_version", release.PreviousVersion, + "features_count", len(release.Features), + "bugfixes_count", len(release.BugFixes), + "breaking_count", len(release.Breaking)) + fmt.Println(trans.GetMessage("release.previous_version", 0, struct{ Version string }{release.PreviousVersion})) fmt.Println(trans.GetMessage("release.next_version", 0, struct { Version string @@ -55,15 +72,23 @@ func previewReleaseAction(releaseSvc releaseService, trans *i18n.Translations) c fmt.Println() if err := releaseSvc.EnrichReleaseContext(ctx, release); err != nil { + log.Warn("failed to enrich release context", "error", err) fmt.Printf("⚠️ %s\n", trans.GetMessage("release.warning_enrich_context", 0, struct{ Error string }{err.Error()})) } notes, err := releaseSvc.GenerateReleaseNotes(ctx, release) if err != nil { + log.Error("failed to generate release notes", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) ui.HandleAppError(err, trans) return fmt.Errorf("%s", trans.GetMessage("release.error_generating_notes", 0, struct{ Error string }{err.Error()})) } + log.Debug("release notes generated", + "title", notes.Title, + "highlights_count", len(notes.Highlights)) + fmt.Println(trans.GetMessage("release.separator", 0, nil)) fmt.Printf("## %s\n\n", notes.Title) fmt.Printf("%s\n\n", notes.Summary) @@ -89,6 +114,10 @@ func previewReleaseAction(releaseSvc releaseService, trans *i18n.Translations) c fmt.Println() } + log.Info("release preview command completed successfully", + "version", release.Version, + "duration_ms", time.Since(start).Milliseconds()) + return nil } } diff --git a/internal/commands/release/publish.go b/internal/commands/release/publish.go index a2d59bb..e57a1e5 100644 --- a/internal/commands/release/publish.go +++ b/internal/commands/release/publish.go @@ -3,9 +3,11 @@ package release import ( "context" "fmt" + "time" "github.com/thomas-vilte/matecommit/internal/commands/completion_helper" "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/ui" "github.com/urfave/cli/v3" ) @@ -47,27 +49,50 @@ func (r *ReleaseCommandFactory) newPublishCommand(trans *i18n.Translations) *cli func publishReleaseAction(releaseSvc releaseService, trans *i18n.Translations) cli.ActionFunc { return func(ctx context.Context, cmd *cli.Command) error { + log := logger.FromContext(ctx) + start := time.Now() + + version := cmd.String("version") + draft := cmd.Bool("draft") + buildBinaries := cmd.Bool("build-binaries") + + log.Info("executing release publish command", + "version", version, + "draft", draft, + "build_binaries", buildBinaries) + release, err := releaseSvc.AnalyzeNextRelease(ctx) if err != nil { + log.Error("failed to analyze next release", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) return fmt.Errorf("%s", trans.GetMessage("release.error_analyzing", 0, struct{ Error string }{err.Error()})) } + log.Debug("release analyzed", + "version", release.Version) + if version := cmd.String("version"); version != "" { release.Version = version } if err := releaseSvc.EnrichReleaseContext(ctx, release); err != nil { + log.Warn("failed to enrich release context", "error", err) fmt.Printf("⚠️ %s\n", trans.GetMessage("release.warning_enrich_context", 0, struct{ Error string }{err.Error()})) } notes, err := releaseSvc.GenerateReleaseNotes(ctx, release) if err != nil { + log.Error("failed to generate release notes", + "error", err, + "duration_ms", time.Since(start).Milliseconds()) ui.HandleAppError(err, trans) return fmt.Errorf("%s", trans.GetMessage("release.error_generating_notes", 0, struct{ Error string }{err.Error()})) } - draft := cmd.Bool("draft") - buildBinaries := cmd.Bool("build-binaries") + log.Debug("release notes generated", + "title", notes.Title) + draftText := "" if draft { draftText = " " + trans.GetMessage("release.as_draft", 0, nil) @@ -80,9 +105,17 @@ func publishReleaseAction(releaseSvc releaseService, err = releaseSvc.PublishRelease(ctx, release, notes, draft, buildBinaries) if err != nil { + log.Error("failed to publish release", + "error", err, + "version", release.Version, + "duration_ms", time.Since(start).Milliseconds()) return fmt.Errorf("%s", trans.GetMessage("release.error_publishing", 0, struct{ Error string }{err.Error()})) } + log.Info("release published successfully", + "version", release.Version, + "draft", draft) + fmt.Println(trans.GetMessage("release.publish_success", 0, struct{ Version string }{release.Version})) if notes.Usage != nil { @@ -90,6 +123,9 @@ func publishReleaseAction(releaseSvc releaseService, ui.PrintTokenUsage(notes.Usage, trans) } + log.Info("release publish command completed successfully", + "duration_ms", time.Since(start).Milliseconds()) + return nil } } diff --git a/internal/commands/suggests_commits/suggests_commits.go b/internal/commands/suggests_commits/suggests_commits.go index 21b579d..749d3e1 100644 --- a/internal/commands/suggests_commits/suggests_commits.go +++ b/internal/commands/suggests_commits/suggests_commits.go @@ -10,6 +10,7 @@ import ( "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/git" "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ui" "github.com/urfave/cli/v3" @@ -80,13 +81,25 @@ func (f *SuggestCommandFactory) createFlags(cfg *config.Config, t *i18n.Translat func (f *SuggestCommandFactory) createAction(cfg *config.Config, t *i18n.Translations) cli.ActionFunc { return func(ctx context.Context, command *cli.Command) error { - emojiFlag := command.Bool("no-emoji") - if emojiFlag { + log := logger.FromContext(ctx) + + count := command.Int("count") + issueNumber := command.Int("issue") + lang := command.String("lang") + noEmoji := command.Bool("no-emoji") + + log.Info("executing suggest command", + "count", count, + "issue_number", issueNumber, + "language", lang, + "no_emoji", noEmoji) + + if noEmoji { cfg.UseEmoji = false } else { cfg.UseEmoji = true } - count := command.Int("count") + if count < 1 || count > 10 { msg := t.GetMessage("invalid_suggestions_count", 0, struct { Min int @@ -118,7 +131,6 @@ func (f *SuggestCommandFactory) createAction(cfg *config.Config, t *i18n.Transla spinner := ui.NewSmartSpinner(t.GetMessage("analyzing_changes", 0, nil)) spinner.Start() - issueNumber := command.Int("issue") var suggestions []models.CommitSuggestion var err error @@ -147,11 +159,18 @@ func (f *SuggestCommandFactory) createAction(cfg *config.Config, t *i18n.Transla duration := time.Since(start) if err != nil { + log.Error("failed to generate suggestions", + "error", err, + "duration_ms", duration.Milliseconds()) spinner.Error(t.GetMessage("ui.error_generating_suggestions", 0, nil)) ui.HandleAppError(err, t) return fmt.Errorf("%s", t.GetMessage("suggestion_generation_error", 0, struct{ Error error }{err})) } + log.Info("suggestions generated successfully", + "count", len(suggestions), + "duration_ms", duration.Milliseconds()) + spinner.Stop() ui.PrintDuration(t.GetMessage("ui.suggestions_generated", 0, struct{ Count int }{len(suggestions)}), duration) return f.commitHandler.HandleSuggestions(ctx, suggestions) diff --git a/internal/git/git_service.go b/internal/git/git_service.go index ab2b00d..d7415e9 100644 --- a/internal/git/git_service.go +++ b/internal/git/git_service.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/regex" ) @@ -27,9 +28,15 @@ func (s *GitService) HasStagedChanges(ctx context.Context) bool { } func (s *GitService) GetChangedFiles(ctx context.Context) ([]string, error) { + log := logger.FromContext(ctx) + + log.Debug("getting changed files") + cmd := exec.CommandContext(ctx, "git", "status", "--porcelain") output, err := cmd.Output() if err != nil { + log.Error("git status failed", + "error", err) return nil, err } @@ -46,19 +53,30 @@ func (s *GitService) GetChangedFiles(ctx context.Context) ([]string, error) { } } + log.Debug("changed files retrieved", + "count", len(changes)) + return changes, nil } func (s *GitService) GetDiff(ctx context.Context) (string, error) { + log := logger.FromContext(ctx) + + log.Debug("executing git diff") + stagedCmd := exec.CommandContext(ctx, "git", "diff", "--cached") stagedOutput, err := stagedCmd.Output() if err != nil { + log.Error("git diff --cached failed", + "error", err) return "", err } unstagedCmd := exec.CommandContext(ctx, "git", "diff") unstageOutput, err := unstagedCmd.Output() if err != nil { + log.Error("git diff failed", + "error", err) return "", err } @@ -80,14 +98,26 @@ func (s *GitService) GetDiff(ctx context.Context) (string, error) { } } } + + log.Debug("git diff completed", + "staged_size", len(stagedOutput), + "unstaged_size", len(unstageOutput), + "total_size", len(combinedDiff)) + return combinedDiff, nil } func (s *GitService) CreateCommit(ctx context.Context, message string) error { + log := logger.FromContext(ctx) + if !s.HasStagedChanges(ctx) { + log.Warn("no staged changes to commit") return errors.ErrNoChanges } + log.Debug("creating git commit", + "message_length", len(message)) + cmd := exec.CommandContext(ctx, "git", "commit", "-m", message) var stderr strings.Builder cmd.Stderr = &stderr @@ -95,6 +125,10 @@ func (s *GitService) CreateCommit(ctx context.Context, message string) error { if err := cmd.Run(); err != nil { stderrStr := strings.TrimSpace(stderr.String()) + log.Error("git commit failed", + "error", err, + "stderr", stderrStr) + if strings.Contains(stderrStr, "Please tell me who you are") || (strings.Contains(stderrStr, "user.name")) && strings.Contains(stderrStr, "user.email") { @@ -110,6 +144,9 @@ func (s *GitService) CreateCommit(ctx context.Context, message string) error { fullErr := fmt.Sprintf("%v: %s", err, stderrStr) return fmt.Errorf("%w: %s", errors.ErrCreateCommit, fullErr) } + + log.Info("git commit created successfully") + return nil } @@ -132,29 +169,58 @@ func (s *GitService) AddFileToStaging(ctx context.Context, file string) error { } func (s *GitService) GetCurrentBranch(ctx context.Context) (string, error) { + log := logger.FromContext(ctx) + + log.Debug("getting current git branch") + cmd := exec.CommandContext(ctx, "git", "branch", "--show-current") output, err := cmd.Output() if err != nil { + log.Error("failed to get current branch", + "error", err) return "", fmt.Errorf("%w: %v", errors.ErrGetBranch, err) } branchName := strings.TrimSpace(string(output)) if branchName == "" { + log.Warn("no branch name found (detached HEAD?)") return "", errors.ErrNoBranch } + log.Debug("current branch retrieved", + "branch", branchName) + return branchName, nil } func (s *GitService) GetRepoInfo(ctx context.Context) (string, string, string, error) { + log := logger.FromContext(ctx) + + log.Debug("getting repository info") + cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin") output, err := cmd.Output() if err != nil { + log.Error("failed to get remote URL", + "error", err) return "", "", "", fmt.Errorf("%w: %v", errors.ErrGetRepoURL, err) } url := strings.TrimSpace(string(output)) - return parseRepoURL(url) + owner, repo, provider, err := parseRepoURL(url) + if err != nil { + log.Error("failed to parse repository URL", + "url", url, + "error", err) + return "", "", "", err + } + + log.Debug("repository info retrieved", + "owner", owner, + "repo", repo, + "provider", provider) + + return owner, repo, provider, nil } func (s *GitService) GetLastTag(ctx context.Context) (string, error) { diff --git a/internal/github/github_service.go b/internal/github/github_service.go index d1e4ab7..a1f220e 100644 --- a/internal/github/github_service.go +++ b/internal/github/github_service.go @@ -14,6 +14,7 @@ import ( "github.com/google/go-github/v80/github" "github.com/thomas-vilte/matecommit/internal/builder" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ports" "github.com/thomas-vilte/matecommit/internal/regex" @@ -184,8 +185,20 @@ func (ghc *GitHubClient) UpdatePR(ctx context.Context, prNumber int, summary mod } func (ghc *GitHubClient) GetPR(ctx context.Context, prNumber int) (models.PRData, error) { + log := logger.FromContext(ctx) + + log.Debug("fetching github pull request", + "owner", ghc.owner, + "repo", ghc.repo, + "pr_number", prNumber) + pr, _, err := ghc.prService.Get(ctx, ghc.owner, ghc.repo, prNumber) if err != nil { + log.Error("failed to fetch github PR", + "error", err, + "owner", ghc.owner, + "repo", ghc.repo, + "pr_number", prNumber) return models.PRData{}, fmt.Errorf("failed to get PR #%d: %w", prNumber, err) } @@ -215,7 +228,7 @@ func (ghc *GitHubClient) GetPR(ctx context.Context, prNumber int) (models.PRData } } - return models.PRData{ + prData := models.PRData{ ID: prNumber, Title: pr.GetTitle(), Creator: pr.GetUser().GetLogin(), @@ -223,7 +236,15 @@ func (ghc *GitHubClient) GetPR(ctx context.Context, prNumber int) (models.PRData Diff: diff, BranchName: pr.GetHead().GetRef(), Description: pr.GetBody(), - }, nil + } + + log.Debug("github PR fetched successfully", + "pr_number", prNumber, + "title", prData.Title, + "commits_count", len(prCommits), + "diff_size", len(diff)) + + return prData, nil } @@ -508,8 +529,20 @@ func (ghc *GitHubClient) GetFileStatsBetweenTags(ctx context.Context, previousTa } func (ghc *GitHubClient) GetIssue(ctx context.Context, issueNumber int) (*models.Issue, error) { + log := logger.FromContext(ctx) + + log.Debug("fetching github issue", + "owner", ghc.owner, + "repo", ghc.repo, + "issue_number", issueNumber) + issue, _, err := ghc.issuesService.Get(ctx, ghc.owner, ghc.repo, issueNumber) if err != nil { + log.Error("failed to fetch github issue", + "error", err, + "owner", ghc.owner, + "repo", ghc.repo, + "issue_number", issueNumber) return nil, fmt.Errorf("error getting issue #%d: %w", issueNumber, err) } @@ -542,6 +575,13 @@ func (ghc *GitHubClient) GetIssue(ctx context.Context, issueNumber int) (*models criteria := extractAcceptanceCriteria(description) + log.Debug("github issue fetched successfully", + "issue_number", issueNumber, + "title", issue.GetTitle(), + "state", state, + "labels_count", len(labels), + "criteria_count", len(criteria)) + return &models.Issue{ ID: int(issue.GetID()), Number: issue.GetNumber(), @@ -556,6 +596,15 @@ func (ghc *GitHubClient) GetIssue(ctx context.Context, issueNumber int) (*models } func (ghc *GitHubClient) CreateIssue(ctx context.Context, title string, body string, labels []string, assignees []string) (*models.Issue, error) { + log := logger.FromContext(ctx) + + log.Info("creating github issue", + "owner", ghc.owner, + "repo", ghc.repo, + "title", title, + "labels_count", len(labels), + "assignees_count", len(assignees)) + issueRequest := &github.IssueRequest{ Title: github.Ptr(title), Body: github.Ptr(body), @@ -565,6 +614,10 @@ func (ghc *GitHubClient) CreateIssue(ctx context.Context, title string, body str ghIssue, _, err := ghc.issuesService.Create(ctx, ghc.owner, ghc.repo, issueRequest) if err != nil { + log.Error("failed to create github issue", + "error", err, + "owner", ghc.owner, + "repo", ghc.repo) return nil, fmt.Errorf("error creating issue: %w", err) } @@ -584,6 +637,11 @@ func (ghc *GitHubClient) CreateIssue(ctx context.Context, title string, body str issue.Labels = append(issue.Labels, label.GetName()) } } + + log.Info("github issue created successfully", + "issue_number", issue.Number, + "issue_url", issue.URL) + return issue, nil } diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index 563e25a..049e81e 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -786,3 +786,7 @@ reason_balance = "Optimal balance between cost and quality" reason_default = "Default model" + +[flags_global] +debug_flag = "Enable debug logging (very verbose, includes file:line)" +verbose_flag = "Enable verbose logging (show info messages)" diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index c640b36..65f6ced 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -804,3 +804,7 @@ reason_high_quality = "Operación de alta calidad, requiere mejor redacción" reason_large = "Operación grande (> 10k tokens), requiere mejor manejo de contexto" reason_balance = "Balance óptimo entre costo y calidad" reason_default = "Modelo por defecto" + +[flags_global] +debug_flag = "Habilitar logging de depuración (muy detallado, incluye archivo:línea)" +verbose_flag = "Habilitar logging detallado (mostrar mensajes informativos)" diff --git a/internal/logger/handler.go b/internal/logger/handler.go new file mode 100644 index 0000000..86a5772 --- /dev/null +++ b/internal/logger/handler.go @@ -0,0 +1,163 @@ +package logger + +import ( + "context" + "fmt" + "io" + "log/slog" + "path/filepath" + "runtime" + "strings" + + "github.com/fatih/color" +) + +// PrettyHandler is a custom slog.Handler for human-friendly CLI output +type PrettyHandler struct { + opts *slog.HandlerOptions + w io.Writer + attrs []slog.Attr + groups []string +} + +func NewPrettyHandler(w io.Writer, opts *slog.HandlerOptions) *PrettyHandler { + if opts == nil { + opts = &slog.HandlerOptions{} + } + return &PrettyHandler{ + opts: opts, + w: w, + attrs: []slog.Attr{}, + } +} + +func (h *PrettyHandler) Enabled(_ context.Context, level slog.Level) bool { + minLevel := slog.LevelWarn + if h.opts.Level != nil { + minLevel = h.opts.Level.Level() + } + return level >= minLevel +} + +func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error { + var buf strings.Builder + + // Level badge with color + levelStr := h.formatLevel(r.Level) + buf.WriteString(levelStr) + buf.WriteString(" ") + + // Message + buf.WriteString(r.Message) + + // Attributes + attrs := make([]string, 0) + r.Attrs(func(a slog.Attr) bool { + attrs = append(attrs, h.formatAttr(a)) + return true + }) + + // Add pre-existing attributes + for _, a := range h.attrs { + attrs = append(attrs, h.formatAttr(a)) + } + + if len(attrs) > 0 { + buf.WriteString(" ") + buf.WriteString(strings.Join(attrs, " ")) + } + + // Source (only in debug mode) + if h.opts.AddSource && r.PC != 0 { + fs := slog.Source{ + Function: "", + File: "", + Line: 0, + } + // Get source from PC using runtime + if r.PC != 0 { + frame, _ := runtime.CallersFrames([]uintptr{r.PC}).Next() + fs.File = frame.File + fs.Line = frame.Line + fs.Function = frame.Function + } + + if fs.File != "" { + file := filepath.Base(fs.File) + source := color.HiBlackString("(%s:%d)", file, fs.Line) + buf.WriteString(" ") + buf.WriteString(source) + } + } + + buf.WriteString("\n") + _, err := h.w.Write([]byte(buf.String())) + return err +} + +func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs)) + copy(newAttrs, h.attrs) + copy(newAttrs[len(h.attrs):], attrs) + + return &PrettyHandler{ + opts: h.opts, + w: h.w, + attrs: newAttrs, + groups: h.groups, + } +} + +func (h *PrettyHandler) WithGroup(name string) slog.Handler { + newGroups := make([]string, len(h.groups)+1) + copy(newGroups, h.groups) + newGroups[len(h.groups)] = name + + return &PrettyHandler{ + opts: h.opts, + w: h.w, + attrs: h.attrs, + groups: newGroups, + } +} + +func (h *PrettyHandler) formatLevel(level slog.Level) string { + var badge string + + switch level { + case slog.LevelDebug: + badge = color.HiBlackString("[DEBUG]") + case slog.LevelInfo: + badge = color.CyanString("[INFO] ") + case slog.LevelWarn: + badge = color.YellowString("[WARN] ") + case slog.LevelError: + badge = color.RedString("[ERROR]") + default: + badge = fmt.Sprintf("[%s]", level.String()) + } + + return badge +} + +func (h *PrettyHandler) formatAttr(a slog.Attr) string { + key := a.Key + val := a.Value.String() + + // Apply group prefix if any + if len(h.groups) > 0 { + key = strings.Join(h.groups, ".") + "." + key + } + + // Color-code certain keys + switch key { + case "error", "err": + return color.RedString("%s=%s", key, val) + case "duration_ms", "duration": + return color.MagentaString("%s=%s", key, val) + case "count", "total", "size": + return color.GreenString("%s=%s", key, val) + default: + return color.HiBlackString("%s=%s", key, val) + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..caf8b88 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,70 @@ +package logger + +import ( + "context" + "log/slog" + "os" +) + +type contextKey struct{} + +var loggerKey = contextKey{} + +func Initialize(debug, verbose bool) { + level := slog.LevelWarn + + if debug { + level = slog.LevelDebug + } else if verbose { + level = slog.LevelInfo + } + + opts := &slog.HandlerOptions{ + Level: level, + AddSource: debug, + } + + if debug { + opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr { + return a + } + } + + handler := NewPrettyHandler(os.Stderr, opts) + slog.SetDefault(slog.New(handler)) +} + +func FromContext(ctx context.Context) *slog.Logger { + if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok { + return l + } + return slog.Default() +} + +func WithLogger(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, loggerKey, logger) +} + +func With(ctx context.Context, args ...any) context.Context { + l := FromContext(ctx).With(args...) + return WithLogger(ctx, l) +} + +func Debug(ctx context.Context, msg string, args ...any) { + FromContext(ctx).Debug(msg, args...) +} + +func Info(ctx context.Context, msg string, args ...any) { + FromContext(ctx).Info(msg, args...) +} + +func Warn(ctx context.Context, msg string, args ...any) { + FromContext(ctx).Warn(msg, args...) +} + +func Error(ctx context.Context, msg string, err error, args ...any) { + if err != nil { + args = append(args, slog.Any("error", err)) + } + FromContext(ctx).Error(msg, args...) +} diff --git a/internal/services/commit_service.go b/internal/services/commit_service.go index 4301b67..de93610 100644 --- a/internal/services/commit_service.go +++ b/internal/services/commit_service.go @@ -10,6 +10,7 @@ import ( "github.com/thomas-vilte/matecommit/internal/config" domainErrors "github.com/thomas-vilte/matecommit/internal/errors" "github.com/thomas-vilte/matecommit/internal/github" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ports" "github.com/thomas-vilte/matecommit/internal/regex" @@ -64,14 +65,47 @@ func NewCommitService(gitSvc commitGitService, ai ports.CommitSummarizer, opts . } func (s *CommitService) GenerateSuggestions(ctx context.Context, count int, issueNumber int, progress func(models.ProgressEvent)) ([]models.CommitSuggestion, error) { + log := logger.FromContext(ctx) + + log.Info("generating commit suggestions", + "count", count, + "issue_number", issueNumber, + ) commitInfo, err := s.buildCommitInfo(ctx, issueNumber, progress) if err != nil { + log.Error("failed to build commit info", + "error", err, + "issue_number", issueNumber, + ) + return nil, err + } + log.Debug("commit info built successfully", + "files_changed", len(commitInfo.Files), + "has_diff", len(commitInfo.Diff) > 0, + "has_issue_info", commitInfo.IssueInfo != nil, + ) + + suggestions, err := s.ai.GenerateSuggestions(ctx, commitInfo, count) + if err != nil { + log.Error("failed to generate suggestions", + "error", err, + "commit_info_size", len(commitInfo.Diff)) return nil, err } - return s.ai.GenerateSuggestions(ctx, commitInfo, count) + + log.Info("suggestions generated successfully", + "count", len(suggestions)) + + return suggestions, nil } func (s *CommitService) buildCommitInfo(ctx context.Context, issueNumber int, progress func(models.ProgressEvent)) (models.CommitInfo, error) { + log := logger.FromContext(ctx) + + log.Debug("building commit info", + "issue_number", issueNumber, + ) + var commitInfo models.CommitInfo if s.ai == nil { @@ -118,12 +152,17 @@ func (s *CommitService) buildCommitInfo(ctx context.Context, issueNumber int, pr commitInfo.TicketInfo = ticketInfo } - detectedIssue := issueNumber - if detectedIssue == 0 { - detectedIssue = s.detectIssueNumber(ctx) + detectedIssueNumber := issueNumber + if detectedIssueNumber == 0 { + detectedIssueNumber = s.detectIssueNumber(ctx) } - if detectedIssue > 0 { + if detectedIssueNumber > 0 { + log.Debug("issue number detected", + "issue_number", detectedIssueNumber, + "source", "branch_or_commits", + ) + vcsClient, err := s.getOrCreateVCSClient(ctx) if err != nil { if progress != nil { @@ -133,14 +172,14 @@ func (s *CommitService) buildCommitInfo(ctx context.Context, issueNumber int, pr }) } } else { - issueInfo, err := vcsClient.GetIssue(ctx, detectedIssue) + issueInfo, err := vcsClient.GetIssue(ctx, detectedIssueNumber) if err != nil { if progress != nil { progress(models.ProgressEvent{ Type: models.ProgressGeneric, Data: &models.ProgressData{ Error: err.Error(), - Number: detectedIssue, + Number: detectedIssueNumber, }, }) } @@ -149,7 +188,7 @@ func (s *CommitService) buildCommitInfo(ctx context.Context, issueNumber int, pr progress(models.ProgressEvent{ Type: models.ProgressIssuesDetected, Data: &models.ProgressData{ - Number: detectedIssue, + Number: detectedIssueNumber, Title: issueInfo.Title, IsAuto: issueNumber == 0, }, @@ -177,15 +216,21 @@ func (s *CommitService) getOrCreateVCSClient(ctx context.Context) (ports.VCSClie return s.vcsClient, nil } + log := logger.FromContext(ctx) + log.Debug("creating VCS client") + if s.config == nil { return nil, domainErrors.ErrConfigMissing } owner, repo, provider, err := s.git.GetRepoInfo(ctx) if err != nil { + logger.Error(ctx, "failed to get repo info for VCS client", err) return nil, domainErrors.NewAppError(domainErrors.TypeGit, "error getting repo info", err) } + log.Debug("repo info retrieved", "owner", owner, "repo", repo, "provider", provider) + vcsConfig, exists := s.config.VCSConfigs[provider] if !exists { if s.config.ActiveVCSProvider != "" { @@ -194,6 +239,7 @@ func (s *CommitService) getOrCreateVCSClient(ctx context.Context) (ports.VCSClie return nil, domainErrors.ErrConfigMissing } provider = s.config.ActiveVCSProvider + log.Debug("using active VCS provider", "provider", provider) } else { return nil, domainErrors.NewAppError(domainErrors.TypeConfiguration, fmt.Sprintf("VCS provider '%s' not configured", provider), nil) } @@ -201,7 +247,7 @@ func (s *CommitService) getOrCreateVCSClient(ctx context.Context) (ports.VCSClie switch provider { case "github": - // Removing 'nil' for translations as per decoupling plan (assuming GitHubClient is updated) + log.Debug("VCS client created successfully", "provider", "github") return github.NewGitHubClient(owner, repo, vcsConfig.Token), nil default: return nil, domainErrors.ErrVCSNotSupported @@ -211,14 +257,20 @@ func (s *CommitService) getOrCreateVCSClient(ctx context.Context) (ports.VCSClie // detectIssueNumber attempts to automatically detect the issue number // Priority: 1) Branch name, 2) Recent commits func (s *CommitService) detectIssueNumber(ctx context.Context) int { + log := logger.FromContext(ctx) + log.Debug("attempting to detect issue number") + if issueNum := s.detectIssueFromBranch(ctx); issueNum > 0 { + log.Debug("issue number detected from branch", "issue_number", issueNum) return issueNum } if issueNum := s.detectIssueFromCommits(ctx); issueNum > 0 { + log.Debug("issue number detected from commits", "issue_number", issueNum) return issueNum } + log.Debug("no issue number detected") return 0 } @@ -227,9 +279,12 @@ func (s *CommitService) detectIssueNumber(ctx context.Context) int { func (s *CommitService) detectIssueFromBranch(ctx context.Context) int { branchName, err := s.git.GetCurrentBranch(ctx) if err != nil { + logger.Debug(ctx, "failed to get current branch for issue detection", "error", err.Error()) return 0 } + logger.Debug(ctx, "checking branch name for issue number", "branch", branchName) + for _, re := range []*regexp.Regexp{ regex.BranchIssueSharp, regex.BranchIssueName, @@ -239,6 +294,7 @@ func (s *CommitService) detectIssueFromBranch(ctx context.Context) int { } { if match := re.FindStringSubmatch(branchName); len(match) > 1 { if num, err := strconv.Atoi(match[1]); err == nil { + logger.Debug(ctx, "issue number found in branch name", "issue_number", num, "branch", branchName) return num } } @@ -252,13 +308,17 @@ func (s *CommitService) detectIssueFromBranch(ctx context.Context) int { func (s *CommitService) detectIssueFromCommits(ctx context.Context) int { commitMessages, err := s.git.GetRecentCommitMessages(ctx, 5) if err != nil { + logger.Debug(ctx, "failed to get recent commits for issue detection", "error", err.Error()) return 0 } + logger.Debug(ctx, "checking recent commits for issue references", "commit_count", len(commitMessages)) + // https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue for _, msg := range commitMessages { if match := regex.GitHubClosedLink.FindStringSubmatch(msg); len(match) > 1 { if num, err := strconv.Atoi(match[1]); err == nil { + logger.Debug(ctx, "issue number found in commit message", "issue_number", num) return num } } diff --git a/internal/services/cost/manager.go b/internal/services/cost/manager.go index 83d1cf1..c94e02a 100644 --- a/internal/services/cost/manager.go +++ b/internal/services/cost/manager.go @@ -3,6 +3,7 @@ package cost import ( "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "time" @@ -55,6 +56,15 @@ func NewManager(budgetDaily float64) (*Manager, error) { // SaveActivity saves an activity record func (m *Manager) SaveActivity(record ActivityRecord) error { + slog.Debug("saving activity record", + "command", record.Command, + "provider", record.Provider, + "model", record.Model, + "tokens_input", record.TokensInput, + "tokens_output", record.TokensOutput, + "cost_usd", record.CostUSD, + "cache_hit", record.CacheHit) + records, err := m.loadHistory() if err != nil { records = []ActivityRecord{} @@ -64,24 +74,39 @@ func (m *Manager) SaveActivity(record ActivityRecord) error { data, err := json.MarshalIndent(records, "", " ") if err != nil { + slog.Error("failed to serialize activity history", + "error", err) return fmt.Errorf("error serializing history: %w", err) } if err := os.WriteFile(m.historyPath, data, 0644); err != nil { + slog.Error("failed to write activity history", + "path", m.historyPath, + "error", err) return fmt.Errorf("error saving history: %w", err) } + slog.Debug("activity record saved successfully", + "total_records", len(records)) + return nil } // CheckBudget checks if the estimated cost exceeds the daily budget func (m *Manager) CheckBudget(estimatedCost float64) (*BudgetStatus, error) { + slog.Debug("checking budget", + "estimated_cost", estimatedCost, + "budget_daily", m.budgetDaily) + if m.budgetDaily <= 0 { + slog.Debug("no budget limit configured") return &BudgetStatus{}, nil } todayTotal, err := m.GetDailyTotal() if err != nil { + slog.Error("failed to get daily total", + "error", err) return nil, err } @@ -107,6 +132,13 @@ func (m *Manager) CheckBudget(estimatedCost float64) (*BudgetStatus, error) { status.WarningLevel = 50 } + slog.Info("budget check completed", + "today_total", todayTotal, + "estimated_cost", estimatedCost, + "percent_used", percentUsed, + "is_exceeded", status.IsExceeded, + "is_warning", status.IsWarning) + return status, nil } diff --git a/internal/services/issue_generator_service.go b/internal/services/issue_generator_service.go index b35b739..edd010b 100644 --- a/internal/services/issue_generator_service.go +++ b/internal/services/issue_generator_service.go @@ -7,6 +7,7 @@ import ( "github.com/thomas-vilte/matecommit/internal/config" domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ports" "github.com/thomas-vilte/matecommit/internal/regex" @@ -20,7 +21,7 @@ type issueGitService interface { // issueTemplateService is a minimal interface for testing purposes type issueTemplateService interface { - GetTemplateByName(name string) (*models.IssueTemplate, error) + GetTemplateByName(ctx context.Context, name string) (*models.IssueTemplate, error) MergeWithGeneratedContent(template *models.IssueTemplate, generated *models.IssueGenerationResult) *models.IssueGenerationResult } @@ -70,24 +71,36 @@ func NewIssueGeneratorService( // GenerateFromDiff generates issue content based on the current git diff. // It analyzes local changes (staged and unstaged) to create an appropriate title, description, and labels. func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint string, skipLabels bool) (*models.IssueGenerationResult, error) { + logger.Info(ctx, "generating issue from diff", + "has_hint", hint != "", + "skip_labels", skipLabels) + if s.ai == nil { + logger.Error(ctx, "AI service not configured", nil) return nil, domainErrors.ErrAPIKeyMissing } diff, err := s.git.GetDiff(ctx) if err != nil { + logger.Error(ctx, "failed to get diff", err) return nil, domainErrors.NewAppError(domainErrors.TypeGit, "failed to get diff", err) } if diff == "" { + logger.Warn(ctx, "no changes to generate issue from") return nil, domainErrors.ErrNoChanges } changedFiles, err := s.git.GetChangedFiles(ctx) if err != nil { + logger.Error(ctx, "failed to get changed files", err) return nil, domainErrors.NewAppError(domainErrors.TypeGit, "failed to get changed files", err) } + logger.Debug(ctx, "git data retrieved", + "diff_size", len(diff), + "files_count", len(changedFiles)) + request := models.IssueGenerationRequest{ Diff: diff, ChangedFiles: changedFiles, @@ -95,27 +108,41 @@ func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint strin Language: s.config.Language, } + logger.Debug(ctx, "calling AI for issue generation from diff") + result, err := s.ai.GenerateIssueContent(ctx, request) if err != nil { + logger.Error(ctx, "failed to generate issue content", err) return nil, domainErrors.NewAppError(domainErrors.TypeAI, "failed to generate issue content", err) } if !skipLabels { smartLabels := s.inferSmartLabels(diff, changedFiles) result.Labels = s.mergeLabels(result.Labels, smartLabels) + logger.Debug(ctx, "labels inferred", + "total_labels", len(result.Labels)) } + logger.Info(ctx, "issue generated from diff successfully", + "title", result.Title) + return result, nil } // GenerateFromDescription generates issue content based on a manual description. // Useful when the user wants to create an issue without having local changes. func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, description string, skipLabels bool) (*models.IssueGenerationResult, error) { + logger.Info(ctx, "generating issue from description", + "description_length", len(description), + "skip_labels", skipLabels) + if s.ai == nil { + logger.Error(ctx, "AI service not configured", nil) return nil, domainErrors.ErrAPIKeyMissing } if description == "" { + logger.Warn(ctx, "empty description provided") return nil, domainErrors.NewAppError(domainErrors.TypeConfiguration, "description is required", nil) } @@ -126,8 +153,11 @@ func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, des request.Language = s.config.Language } + logger.Debug(ctx, "calling AI for issue generation from description") + result, err := s.ai.GenerateIssueContent(ctx, request) if err != nil { + logger.Error(ctx, "failed to generate issue content", err) return nil, domainErrors.NewAppError(domainErrors.TypeAI, "failed to generate issue content", err) } @@ -135,23 +165,39 @@ func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, des result.Labels = []string{} } + logger.Info(ctx, "issue generated from description successfully", + "title", result.Title) + return result, nil } func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool) (*models.IssueGenerationResult, error) { + logger.Info(ctx, "generating issue from PR", + "pr_number", prNumber, + "has_hint", hint != "", + "skip_labels", skipLabels) + if s.ai == nil { + logger.Error(ctx, "AI service not configured", nil) return nil, domainErrors.ErrAPIKeyMissing } if s.vcsClient == nil { + logger.Error(ctx, "VCS client not configured", nil) return nil, domainErrors.ErrConfigMissing } prData, err := s.vcsClient.GetPR(ctx, prNumber) if err != nil { + logger.Error(ctx, "failed to get PR data", err, + "pr_number", prNumber) return nil, domainErrors.NewAppError(domainErrors.TypeVCS, "failed to get PR", err) } + logger.Debug(ctx, "PR data fetched for issue generation", + "pr_number", prNumber, + "diff_size", len(prData.Diff)) + var contextBuilder strings.Builder contextBuilder.WriteString(fmt.Sprintf("Pull Request #%d: %s\n\n", prNumber, prData.Title)) @@ -179,8 +225,12 @@ func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int Language: s.config.Language, } + logger.Debug(ctx, "calling AI for issue generation from PR") + result, err := s.ai.GenerateIssueContent(ctx, request) if err != nil { + logger.Error(ctx, "failed to generate issue content from PR", err, + "pr_number", prNumber) return nil, domainErrors.NewAppError(domainErrors.TypeAI, "failed to generate issue content", err) } @@ -189,12 +239,19 @@ func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int if !skipLabels { smartLabels := s.inferSmartLabels(prData.Diff, changedFiles) result.Labels = s.mergeLabels(result.Labels, smartLabels) + logger.Debug(ctx, "labels inferred from PR", + "total_labels", len(result.Labels)) } + + logger.Info(ctx, "issue generated from PR successfully", + "pr_number", prNumber, + "title", result.Title) + return result, nil } func (s *IssueGeneratorService) GenerateWithTemplate(ctx context.Context, templateName string, hint string, fromDiff bool, description string, skipLabels bool) (*models.IssueGenerationResult, error) { - template, err := s.templateService.GetTemplateByName(templateName) + template, err := s.templateService.GetTemplateByName(ctx, templateName) if err != nil { return nil, domainErrors.NewAppError(domainErrors.TypeConfiguration, fmt.Sprintf("failed to load template: %s", templateName), err) } diff --git a/internal/services/issue_generator_service_test.go b/internal/services/issue_generator_service_test.go index 16b54ba..cd29b94 100644 --- a/internal/services/issue_generator_service_test.go +++ b/internal/services/issue_generator_service_test.go @@ -4,10 +4,10 @@ import ( "context" "testing" - "github.com/thomas-vilte/matecommit/internal/config" - "github.com/thomas-vilte/matecommit/internal/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/thomas-vilte/matecommit/internal/config" + "github.com/thomas-vilte/matecommit/internal/models" ) func TestIssueGeneratorService(t *testing.T) { @@ -122,7 +122,7 @@ func TestIssueGeneratorService(t *testing.T) { Assignees: []string{"tester"}, } - mockTemplate.On("GetTemplateByName", "bug_report").Return(template, nil) + mockTemplate.On("GetTemplateByName", ctx, "bug_report").Return(template, nil) mockGit.On("GetDiff", ctx).Return("some changes", nil) mockGit.On("GetChangedFiles", ctx).Return([]string{"file.go"}, nil) mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(generated, nil) diff --git a/internal/services/issue_template_service.go b/internal/services/issue_template_service.go index d2d9045..ada0591 100644 --- a/internal/services/issue_template_service.go +++ b/internal/services/issue_template_service.go @@ -1,6 +1,7 @@ package services import ( + "context" "fmt" "os" "path/filepath" @@ -8,6 +9,7 @@ import ( "github.com/thomas-vilte/matecommit/internal/config" domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "gopkg.in/yaml.v3" ) @@ -32,9 +34,10 @@ func NewIssueTemplateService(opts ...IssueOption) *IssueTemplateService { return s } -func (s *IssueTemplateService) GetTemplatesDir() (string, error) { +func (s *IssueTemplateService) GetTemplatesDir(ctx context.Context) (string, error) { cwd, err := os.Getwd() if err != nil { + logger.Error(ctx, "failed to get current working directory", err) return "", domainErrors.NewAppError(domainErrors.TypeInternal, "failed to get current working directory", err) } @@ -50,21 +53,24 @@ func (s *IssueTemplateService) GetTemplatesDir() (string, error) { templatesDir = filepath.Join(cwd, ".github", "ISSUE_TEMPLATE") } + logger.Debug(ctx, "identified templates directory", "provider", provider, "path", templatesDir) return templatesDir, nil } -func (s *IssueTemplateService) ListTemplates() ([]models.TemplateMetadata, error) { - templatesDir, err := s.GetTemplatesDir() +func (s *IssueTemplateService) ListTemplates(ctx context.Context) ([]models.TemplateMetadata, error) { + templatesDir, err := s.GetTemplatesDir(ctx) if err != nil { return nil, err } if _, err := os.Stat(templatesDir); os.IsNotExist(err) { + logger.Debug(ctx, "templates directory does not exist, returning empty list", "path", templatesDir) return []models.TemplateMetadata{}, nil } entries, err := os.ReadDir(templatesDir) if err != nil { + logger.Error(ctx, "failed to read templates directory", err, "path", templatesDir) return nil, domainErrors.NewAppError(domainErrors.TypeInternal, "failed to read templates directory", err) } @@ -75,8 +81,9 @@ func (s *IssueTemplateService) ListTemplates() ([]models.TemplateMetadata, error } filePath := filepath.Join(templatesDir, entry.Name()) - template, err := s.LoadTemplate(filePath) + template, err := s.LoadTemplate(ctx, filePath) if err != nil { + logger.Warn(ctx, "skipping invalid template", "path", filePath, "error", err) continue } @@ -87,32 +94,35 @@ func (s *IssueTemplateService) ListTemplates() ([]models.TemplateMetadata, error }) } + logger.Debug(ctx, "listed templates", "count", len(templates)) return templates, nil } -func (s *IssueTemplateService) LoadTemplate(filePath string) (*models.IssueTemplate, error) { +func (s *IssueTemplateService) LoadTemplate(ctx context.Context, filePath string) (*models.IssueTemplate, error) { content, err := os.ReadFile(filePath) if err != nil { + logger.Error(ctx, "failed to read template file", err, "path", filePath) return nil, domainErrors.NewAppError(domainErrors.TypeConfiguration, fmt.Sprintf("failed to read template file: %s", filePath), err) } - return s.parseTemplate(string(content), filePath) + return s.parseTemplate(ctx, string(content), filePath) } -func (s *IssueTemplateService) parseTemplate(content string, filePath string) (*models.IssueTemplate, error) { +func (s *IssueTemplateService) parseTemplate(ctx context.Context, content string, filePath string) (*models.IssueTemplate, error) { template := &models.IssueTemplate{ FilePath: filePath, } - // For .yml files, we parse all content directly as YAML if err := yaml.Unmarshal([]byte(content), template); err != nil { + logger.Error(ctx, "failed to parse YAML template", err, "path", filePath) return nil, domainErrors.NewAppError(domainErrors.TypeConfiguration, fmt.Sprintf("failed to parse YAML template: %s", filePath), err) } + logger.Debug(ctx, "successfully loaded/parsed template", "name", template.Name, "path", filePath) return template, nil } -func (s *IssueTemplateService) GetTemplateByName(name string) (*models.IssueTemplate, error) { - templatesDir, err := s.GetTemplatesDir() +func (s *IssueTemplateService) GetTemplateByName(ctx context.Context, name string) (*models.IssueTemplate, error) { + templatesDir, err := s.GetTemplatesDir(ctx) if err != nil { return nil, err } @@ -125,20 +135,22 @@ func (s *IssueTemplateService) GetTemplateByName(name string) (*models.IssueTemp for _, path := range possiblePaths { if _, err := os.Stat(path); err == nil { - return s.LoadTemplate(path) + return s.LoadTemplate(ctx, path) } } + logger.Warn(ctx, "template not found by name", "name", name, "searched_paths", possiblePaths) return nil, domainErrors.NewAppError(domainErrors.TypeConfiguration, fmt.Sprintf("template '%s' not found", name), nil) } -func (s *IssueTemplateService) InitializeTemplates(force bool) error { - templatesDir, err := s.GetTemplatesDir() +func (s *IssueTemplateService) InitializeTemplates(ctx context.Context, force bool) error { + templatesDir, err := s.GetTemplatesDir(ctx) if err != nil { return err } if err := os.MkdirAll(templatesDir, 0755); err != nil { + logger.Error(ctx, "failed to create templates directory", err, "path", templatesDir) return domainErrors.NewAppError(domainErrors.TypeInternal, "failed to create templates directory", err) } @@ -155,16 +167,20 @@ func (s *IssueTemplateService) InitializeTemplates(force bool) error { filePath := filepath.Join(templatesDir, filename) if _, err := os.Stat(filePath); err == nil && !force { + logger.Debug(ctx, "template already exists, skipping", "path", filePath) skipped++ continue } if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + logger.Error(ctx, "failed to write template file during initialization", err, "path", filePath) return domainErrors.NewAppError(domainErrors.TypeInternal, fmt.Sprintf("failed to write template: %s", filePath), err) } + logger.Info(ctx, "successfully created template", "path", filePath) created++ } + logger.Info(ctx, "template initialization complete", "created", created, "skipped", skipped) if created == 0 && skipped > 0 { return domainErrors.NewAppError(domainErrors.TypeConfiguration, "templates_already_exist", nil) } diff --git a/internal/services/issue_template_service_test.go b/internal/services/issue_template_service_test.go index 85aa6f6..afd0678 100644 --- a/internal/services/issue_template_service_test.go +++ b/internal/services/issue_template_service_test.go @@ -1,14 +1,15 @@ package services import ( + "context" "os" "path/filepath" "testing" - "github.com/thomas-vilte/matecommit/internal/config" - "github.com/thomas-vilte/matecommit/internal/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/config" + "github.com/thomas-vilte/matecommit/internal/models" ) func TestIssueTemplateService_GetTemplatesDir(t *testing.T) { @@ -40,7 +41,7 @@ func TestIssueTemplateService_GetTemplatesDir(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg := &config.Config{ActiveVCSProvider: tt.provider} service := NewIssueTemplateService(WithTemplateConfig(cfg)) - dir, err := service.GetTemplatesDir() + dir, err := service.GetTemplatesDir(context.Background()) assert.NoError(t, err) assert.Equal(t, tt.expected, dir) @@ -71,7 +72,7 @@ body: label: "What happened?" validations: required: true` - template, err := service.parseTemplate(content, "test.yml") + template, err := service.parseTemplate(context.Background(), content, "test.yml") require.NoError(t, err) assert.Equal(t, "Bug Report", template.Name) @@ -88,7 +89,7 @@ about: Suggest an idea title: '[FEATURE] ' labels: - enhancement` - template, err := service.parseTemplate(content, "test.yml") + template, err := service.parseTemplate(context.Background(), content, "test.yml") require.NoError(t, err) assert.Equal(t, "Feature Request", template.Name) @@ -100,7 +101,7 @@ labels: t.Run("Invalid YAML", func(t *testing.T) { content := `name: : invalid title: [unclosed` - _, err := service.parseTemplate(content, "test.yml") + _, err := service.parseTemplate(context.Background(), content, "test.yml") assert.Error(t, err) assert.Contains(t, err.Error(), "CONFIGURATION: failed to parse YAML template") }) @@ -122,7 +123,7 @@ func TestIssueTemplateService_FilesystemOps(t *testing.T) { service := NewIssueTemplateService(WithTemplateConfig(cfg)) t.Run("InitializeTemplates", func(t *testing.T) { - err := service.InitializeTemplates(false) + err := service.InitializeTemplates(context.Background(), false) assert.NoError(t, err) templatesDir := filepath.Join(tmpDir, ".github", "ISSUE_TEMPLATE") @@ -133,37 +134,37 @@ func TestIssueTemplateService_FilesystemOps(t *testing.T) { }) t.Run("InitializeTemplates - Already exists", func(t *testing.T) { - err := service.InitializeTemplates(false) + err := service.InitializeTemplates(context.Background(), false) assert.Error(t, err) assert.Contains(t, err.Error(), "CONFIGURATION: templates_already_exist") - err = service.InitializeTemplates(true) + err = service.InitializeTemplates(context.Background(), true) assert.NoError(t, err) }) t.Run("ListTemplates", func(t *testing.T) { - templates, err := service.ListTemplates() + templates, err := service.ListTemplates(context.Background()) assert.NoError(t, err) assert.Len(t, templates, 3) err = os.WriteFile(filepath.Join(tmpDir, ".github", "ISSUE_TEMPLATE", "test.txt"), []byte("..."), 0644) require.NoError(t, err) - templates, err = service.ListTemplates() + templates, err = service.ListTemplates(context.Background()) assert.NoError(t, err) assert.Len(t, templates, 3) }) t.Run("GetTemplateByName", func(t *testing.T) { - template, err := service.GetTemplateByName("bug_report") + template, err := service.GetTemplateByName(context.Background(), "bug_report") assert.NoError(t, err) assert.NotNil(t, template) - template, err = service.GetTemplateByName("bug_report.yml") + template, err = service.GetTemplateByName(context.Background(), "bug_report.yml") assert.NoError(t, err) assert.NotNil(t, template) - _, err = service.GetTemplateByName("non_existent") + _, err = service.GetTemplateByName(context.Background(), "non_existent") assert.Error(t, err) assert.Contains(t, err.Error(), "not found") }) @@ -254,7 +255,7 @@ func TestIssueTemplateService_MergeWithGeneratedContent(t *testing.T) { func TestIssueTemplateService_GetTemplateByName_NotFound(t *testing.T) { cfg := &config.Config{ActiveVCSProvider: "github"} service := NewIssueTemplateService(WithTemplateConfig(cfg)) - _, err := service.GetTemplateByName("ghost") + _, err := service.GetTemplateByName(context.Background(), "ghost") assert.Error(t, err) } diff --git a/internal/services/mocks.go b/internal/services/mocks.go index a78d10b..87e71e1 100644 --- a/internal/services/mocks.go +++ b/internal/services/mocks.go @@ -3,8 +3,8 @@ package services import ( "context" - "github.com/thomas-vilte/matecommit/internal/models" "github.com/stretchr/testify/mock" + "github.com/thomas-vilte/matecommit/internal/models" ) type ( @@ -252,34 +252,34 @@ func (m *MockIssueContentGenerator) GenerateIssueContent(ctx context.Context, re return args.Get(0).(*models.IssueGenerationResult), args.Error(1) } -func (m *MockIssueTemplateService) GetTemplatesDir() (string, error) { - args := m.Called() +func (m *MockIssueTemplateService) GetTemplatesDir(ctx context.Context) (string, error) { + args := m.Called(ctx) return args.String(0), args.Error(1) } -func (m *MockIssueTemplateService) ListTemplates() ([]models.TemplateMetadata, error) { - args := m.Called() +func (m *MockIssueTemplateService) ListTemplates(ctx context.Context) ([]models.TemplateMetadata, error) { + args := m.Called(ctx) return args.Get(0).([]models.TemplateMetadata), args.Error(1) } -func (m *MockIssueTemplateService) LoadTemplate(filePath string) (*models.IssueTemplate, error) { - args := m.Called(filePath) +func (m *MockIssueTemplateService) LoadTemplate(ctx context.Context, filePath string) (*models.IssueTemplate, error) { + args := m.Called(ctx, filePath) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*models.IssueTemplate), args.Error(1) } -func (m *MockIssueTemplateService) GetTemplateByName(name string) (*models.IssueTemplate, error) { - args := m.Called(name) +func (m *MockIssueTemplateService) GetTemplateByName(ctx context.Context, name string) (*models.IssueTemplate, error) { + args := m.Called(ctx, name) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*models.IssueTemplate), args.Error(1) } -func (m *MockIssueTemplateService) InitializeTemplates(force bool) error { - args := m.Called(force) +func (m *MockIssueTemplateService) InitializeTemplates(ctx context.Context, force bool) error { + args := m.Called(ctx, force) return args.Error(0) } diff --git a/internal/services/pull_request_service.go b/internal/services/pull_request_service.go index dc30941..90efa1f 100644 --- a/internal/services/pull_request_service.go +++ b/internal/services/pull_request_service.go @@ -8,6 +8,7 @@ import ( "github.com/thomas-vilte/matecommit/internal/ai" "github.com/thomas-vilte/matecommit/internal/config" domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" ) @@ -58,15 +59,30 @@ func NewPRService(opts ...PROption) *PRService { } func (s *PRService) SummarizePR(ctx context.Context, prNumber int, progress func(models.ProgressEvent)) (models.PRSummary, error) { + log := logger.FromContext(ctx) + + log.Info("summarizing PR", + "pr_number", prNumber) + if s.aiService == nil { + log.Error("AI service not configured") return models.PRSummary{}, domainErrors.ErrAPIKeyMissing } prData, err := s.vcsClient.GetPR(ctx, prNumber) if err != nil { + log.Error("failed to get PR data", + "error", err, + "pr_number", prNumber) return models.PRSummary{}, domainErrors.NewAppError(domainErrors.TypeVCS, "error getting PR", err) } + log.Debug("PR data fetched", + "pr_number", prNumber, + "title", prData.Title, + "commits_count", len(prData.Commits), + "diff_size", len(prData.Diff)) + var commitMessages []string for _, commit := range prData.Commits { commitMessages = append(commitMessages, commit.Message) @@ -76,6 +92,10 @@ func (s *PRService) SummarizePR(ctx context.Context, prNumber int, progress func if err == nil && len(issues) > 0 { prData.RelatedIssues = issues + log.Debug("issues detected in PR", + "pr_number", prNumber, + "issues_count", len(issues)) + issueNums := make([]string, len(issues)) for i, issue := range issues { issueNums[i] = fmt.Sprintf("#%d", issue.Number) @@ -92,10 +112,19 @@ func (s *PRService) SummarizePR(ctx context.Context, prNumber int, progress func } } + log.Debug("building PR prompt", + "has_issues", len(prData.RelatedIssues) > 0) + prompt := s.buildPRPrompt(prData) + log.Debug("calling AI for PR summary generation", + "pr_number", prNumber) + summary, err := s.aiService.GeneratePRSummary(ctx, prompt) if err != nil { + log.Error("failed to generate PR summary", + "error", err, + "pr_number", prNumber) return models.PRSummary{}, domainErrors.NewAppError(domainErrors.TypeAI, "error generating PR summary", err) } @@ -134,11 +163,21 @@ func (s *PRService) SummarizePR(ctx context.Context, prNumber int, progress func summary.Body += testPlan } + log.Info("updating PR with summary", + "pr_number", prNumber, + "labels_count", len(summary.Labels)) + err = s.vcsClient.UpdatePR(ctx, prNumber, summary) if err != nil { + log.Error("failed to update PR", + "error", err, + "pr_number", prNumber) return models.PRSummary{}, domainErrors.NewAppError(domainErrors.TypeVCS, "error updating PR", err) } + log.Info("PR summarized and updated successfully", + "pr_number", prNumber) + return summary, nil } diff --git a/internal/services/release_service.go b/internal/services/release_service.go index 06afd35..52d1597 100644 --- a/internal/services/release_service.go +++ b/internal/services/release_service.go @@ -11,6 +11,7 @@ import ( "github.com/thomas-vilte/matecommit/internal/config" "github.com/thomas-vilte/matecommit/internal/dependency" domainErrors "github.com/thomas-vilte/matecommit/internal/errors" + "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ports" "github.com/thomas-vilte/matecommit/internal/regex" @@ -111,6 +112,23 @@ func (s *ReleaseService) AnalyzeNextRelease(ctx context.Context) (*models.Releas } func (s *ReleaseService) GenerateReleaseNotes(ctx context.Context, release *models.Release) (*models.ReleaseNotes, error) { + log := logger.FromContext(ctx) + + log.Info("generating release notes", + "version", release.Version, + "previous_version", release.PreviousVersion, + ) + + log.Debug("categorizing commits", + "total_commits", len(release.AllCommits), + ) + + log.Debug("commits categorized", + "featues", len(release.Features), + "fixes", len(release.BugFixes), + "breaking", len(release.Breaking), + "other", len(release.Other), + ) if s.notesGen == nil { return s.generateBasicNotes(release), nil } @@ -119,19 +137,68 @@ func (s *ReleaseService) GenerateReleaseNotes(ctx context.Context, release *mode } func (s *ReleaseService) PublishRelease(ctx context.Context, release *models.Release, notes *models.ReleaseNotes, draft bool, buildBinaries bool) error { + log := logger.FromContext(ctx) + + log.Info("publishing release", + "version", release.Version, + "draft", draft, + "build_binaries", buildBinaries) + if s.vcsClient == nil { + log.Error("VCS client not configured for release publishing") return domainErrors.ErrConfigMissing } - return s.vcsClient.CreateRelease(ctx, release, notes, draft, buildBinaries) + if err := s.vcsClient.CreateRelease(ctx, release, notes, draft, buildBinaries); err != nil { + log.Error("failed to publish release", + "error", err, + "version", release.Version) + return err + } + + log.Info("release published successfully", + "version", release.Version, + "draft", draft) + + return nil } func (s *ReleaseService) CreateTag(ctx context.Context, version, message string) error { - return s.git.CreateTag(ctx, version, message) + log := logger.FromContext(ctx) + + log.Info("creating git tag", + "version", version) + + if err := s.git.CreateTag(ctx, version, message); err != nil { + log.Error("failed to create git tag", + "error", err, + "version", version) + return err + } + + log.Info("git tag created successfully", + "version", version) + + return nil } func (s *ReleaseService) PushTag(ctx context.Context, version string) error { - return s.git.PushTag(ctx, version) + log := logger.FromContext(ctx) + + log.Info("pushing git tag", + "version", version) + + if err := s.git.PushTag(ctx, version); err != nil { + log.Error("failed to push git tag", + "error", err, + "version", version) + return err + } + + log.Info("git tag pushed successfully", + "version", version) + + return nil } func (s *ReleaseService) GetRelease(ctx context.Context, version string) (*models.VCSRelease, error) { @@ -142,47 +209,101 @@ func (s *ReleaseService) GetRelease(ctx context.Context, version string) (*model } func (s *ReleaseService) UpdateRelease(ctx context.Context, version, body string) error { + log := logger.FromContext(ctx) + + log.Info("updating release", + "version", version) + if s.vcsClient == nil { + log.Error("VCS client not configured for updating release") return domainErrors.ErrConfigMissing } - return s.vcsClient.UpdateRelease(ctx, version, body) + + if err := s.vcsClient.UpdateRelease(ctx, version, body); err != nil { + log.Error("failed to update release", + "error", err, + "version", version) + return err + } + + log.Info("release updated successfully", + "version", version) + + return nil } func (s *ReleaseService) EnrichReleaseContext(ctx context.Context, release *models.Release) error { + log := logger.FromContext(ctx) + + log.Info("enriching release context", + "version", release.Version, + "previous_version", release.PreviousVersion) + if s.vcsClient == nil { + log.Error("VCS client not configured for enriching release context") return domainErrors.ErrConfigMissing } if issues, err := s.vcsClient.GetClosedIssuesBetweenTags(ctx, release.PreviousVersion, release.Version); err == nil { release.ClosedIssues = issues + log.Debug("closed issues fetched", + "count", len(issues)) } if prs, err := s.vcsClient.GetMergedPRsBetweenTags(ctx, release.PreviousVersion, release.Version); err == nil { release.MergedPRs = prs + log.Debug("merged PRs fetched", + "count", len(prs)) } if contributors, err := s.vcsClient.GetContributorsBetweenTags(ctx, release.PreviousVersion, release.Version); err == nil { release.Contributors = contributors release.NewContributors = contributors + log.Debug("contributors fetched", + "count", len(contributors)) } if stats, err := s.vcsClient.GetFileStatsBetweenTags(ctx, release.PreviousVersion, release.Version); err == nil { release.FileStats = *stats + log.Debug("file stats fetched", + "files_changed", stats.FilesChanged, + "insertions", stats.Insertions, + "deletions", stats.Deletions) } if deps, err := s.analyzeDependencyChanges(ctx, release); err == nil { release.Dependencies = deps + log.Debug("dependencies analyzed") } + log.Info("release context enriched successfully") + return nil } func (s *ReleaseService) UpdateLocalChangelog(release *models.Release, notes *models.ReleaseNotes) error { const changelogFile = "CHANGELOG.md" + log := logger.FromContext(context.Background()) + + log.Debug("updating local changelog", + "version", release.Version, + "file", changelogFile) + newContent := s.buildChangelogFromNotes(context.Background(), release, notes) - return s.prependToChangelog(changelogFile, newContent) + if err := s.prependToChangelog(changelogFile, newContent); err != nil { + log.Error("failed to update changelog", + "error", err, + "file", changelogFile) + return err + } + + log.Info("changelog updated successfully", + "file", changelogFile, + "version", release.Version) + + return nil } func (s *ReleaseService) prependToChangelog(filename, newContent string) error { diff --git a/matecommit b/matecommit new file mode 100755 index 0000000..9c47989 Binary files /dev/null and b/matecommit differ