Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion cmd/matecommit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,15 @@ func main() {
// through the slog handler at Info level. With the handler's default
// Warn threshold, that silently drops every fatal error unless --debug
// is passed — and even then it prints mislabeled as "[INFO]".
//
// Commands are expected to display their own errors via ui.PrintError /
// ui.HandleAppError before returning them (wrapped with ui.Shown) — this
// is the last-resort net for the few paths that don't, so it only prints
// when nothing has shown the error yet.
if err := app.Run(context.Background(), os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
if !ui.IsShown(err) {
fmt.Fprintln(os.Stderr, err)
}
os.Exit(1)
}
}
Expand Down
10 changes: 10 additions & 0 deletions internal/ai/gemini/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ func extractTextFromMap(respMap map[string]interface{}) string {
return result.String()
}

// unescapeLiteralNewlines converts a literal two-character "\n" escape
// sequence into a real newline character. Despite explicit prompt
// instructions asking the model not to, it sometimes still emits the
// escape sequence as text instead of an actual line break inside
// multi-line Markdown fields (PR bodies, issue descriptions) — this is a
// deterministic safety net for when that instruction isn't followed.
func unescapeLiteralNewlines(s string) string {
return strings.ReplaceAll(s, `\n`, "\n")
}

// CleanLabels cleans and validates labels, keeping only the allowed ones.
// It accepts a list of labels to clean and a list of available labels from the repository.
// If availableLabels is empty, it falls back to a default list of common labels.
Expand Down
18 changes: 18 additions & 0 deletions internal/ai/gemini/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,24 @@ func TestExtractUsage(t *testing.T) {
})
}

func TestUnescapeLiteralNewlines(t *testing.T) {
t.Run("replaces literal backslash-n with a real newline", func(t *testing.T) {
input := `## Summary\nThis is the body.\n\n## Changes\n- one\n- two`
result := unescapeLiteralNewlines(input)
assert.Equal(t, "## Summary\nThis is the body.\n\n## Changes\n- one\n- two", result)
assert.NotContains(t, result, `\n`)
})

t.Run("leaves real newlines untouched", func(t *testing.T) {
input := "## Summary\nAlready a real newline."
assert.Equal(t, input, unescapeLiteralNewlines(input))
})

t.Run("empty string stays empty", func(t *testing.T) {
assert.Equal(t, "", unescapeLiteralNewlines(""))
})
}

func TestGetGenerateConfig(t *testing.T) {
t.Run("default config", func(t *testing.T) {
cfg := GetGenerateConfig("gemini-1.5-flash", "", nil)
Expand Down
2 changes: 1 addition & 1 deletion internal/ai/gemini/issue_content_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ func (s *GeminiIssueContentGenerator) parseIssueResponse(content string) (*model

result := &models.IssueGenerationResult{
Title: strings.TrimSpace(jsonResult.Title),
Description: strings.TrimSpace(jsonResult.Description),
Description: unescapeLiteralNewlines(strings.TrimSpace(jsonResult.Description)),
Labels: jsonResult.Labels,
}

Expand Down
2 changes: 1 addition & 1 deletion internal/ai/gemini/pull_requests_summarizer_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prContent

return models.PRSummary{
Title: jsonSummary.Title,
Body: jsonSummary.Body,
Body: unescapeLiteralNewlines(jsonSummary.Body),
Labels: CleanLabels(jsonSummary.Labels, availableLabels),
Usage: usage,
}, nil
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/config/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (d *DoctorCommand) runHealthCheck(ctx context.Context, t *i18n.Translations
} else if len(errors) == 0 {
ui.PrintWarning(t.GetMessage("doctor.has_warnings", 0, nil))
} else {
ui.PrintError(os.Stdout, t.GetMessage("doctor.has_errors", 0, nil))
_ = ui.PrintError(os.Stdout, t.GetMessage("doctor.has_errors", 0, nil))
}

fmt.Println()
Expand Down
15 changes: 7 additions & 8 deletions internal/commands/config/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,7 @@ func initConfigAction(cfg *config.Config, t *i18n.Translations) cli.ActionFunc {
return func(ctx context.Context, command *cli.Command) error {
localCfg, useLocal, err := resolveTargetConfig(command, cfg, t)
if err != nil {
ui.PrintError(os.Stdout, err.Error())
return err
return ui.PrintError(os.Stdout, err.Error())
}

if useLocal {
Expand Down Expand Up @@ -454,7 +453,7 @@ func validateGeminiAPIKey(ctx context.Context, apiKey string, t *i18n.Translatio
summarizer, err := gemini.NewGeminiCommitSummarizer(testCtx, testCfg, nil)
if err != nil {
spinner.Error(t.GetMessage("config.api_key_invalid", 0, nil))
ui.PrintError(os.Stdout, t.GetMessage("config.check_api_key_error", 0, struct{ Error string }{err.Error()}))
_ = ui.PrintError(os.Stdout, t.GetMessage("config.check_api_key_error", 0, struct{ Error string }{err.Error()}))
return false
}

Expand All @@ -463,7 +462,7 @@ func validateGeminiAPIKey(ctx context.Context, apiKey string, t *i18n.Translatio
// is authorized — CountTokens is the cheapest real call available.
if _, err := summarizer.CountTokens(testCtx, "ping"); err != nil {
spinner.Error(t.GetMessage("config.api_key_invalid", 0, nil))
ui.PrintError(os.Stdout, t.GetMessage("config.check_api_key_error", 0, struct{ Error string }{err.Error()}))
_ = ui.PrintError(os.Stdout, t.GetMessage("config.check_api_key_error", 0, struct{ Error string }{err.Error()}))
return false
}

Expand All @@ -488,7 +487,7 @@ func validateGitHubToken(ctx context.Context, token string, t *i18n.Translations
user, resp, err := client.Users.Get(testCtx, "")
if err != nil {
spinner.Error(t.GetMessage("config.github_token_invalid", 0, nil))
ui.PrintError(os.Stdout, t.GetMessage("config.check_token_error", 0, struct{ Error string }{err.Error()}))
_ = ui.PrintError(os.Stdout, t.GetMessage("config.check_token_error", 0, struct{ Error string }{err.Error()}))
return false
}

Expand Down Expand Up @@ -573,7 +572,7 @@ func validateJiraConnection(ctx context.Context, baseURL, email, token string, t
req, err := http.NewRequestWithContext(testCtx, "GET", testURL, nil)
if err != nil {
spinner.Error(t.GetMessage("config.jira_connection_failed", 0, nil))
ui.PrintError(os.Stdout, t.GetMessage("config.jira_error_creating_request", 0, struct{ Error error }{err}))
_ = ui.PrintError(os.Stdout, t.GetMessage("config.jira_error_creating_request", 0, struct{ Error error }{err}))
return false
}

Expand All @@ -583,7 +582,7 @@ func validateJiraConnection(ctx context.Context, baseURL, email, token string, t
resp, err := client.Do(req)
if err != nil {
spinner.Error(t.GetMessage("config.jira_connection_failed", 0, nil))
ui.PrintError(os.Stdout, t.GetMessage("config.check_jira_error", 0, struct{ Error string }{err.Error()}))
_ = ui.PrintError(os.Stdout, t.GetMessage("config.check_jira_error", 0, struct{ Error string }{err.Error()}))
return false
}
defer func() {
Expand All @@ -603,7 +602,7 @@ func validateJiraConnection(ctx context.Context, baseURL, email, token string, t
default:
errorMsg = t.GetMessage("config.jira_http_error", 0, struct{ Code int }{resp.StatusCode})
}
ui.PrintError(os.Stdout, errorMsg)
_ = ui.PrintError(os.Stdout, errorMsg)
return false
}

Expand Down
29 changes: 8 additions & 21 deletions internal/commands/config/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,42 +32,34 @@ func (c *ConfigCommandFactory) newSetCommand(t *i18n.Translations, cfg *config.C
},
Action: func(ctx context.Context, command *cli.Command) error {
if command.Args().Len() < 2 {
ui.PrintError(os.Stdout, t.GetMessage("config_set_error_args", 0, nil))
return fmt.Errorf("missing arguments")
return ui.PrintError(os.Stdout, t.GetMessage("config_set_error_args", 0, nil))
}

key := strings.ToLower(command.Args().Get(0))
value := command.Args().Get(1)

targetCfg, useLocal, err := resolveTargetConfig(command, cfg, t)
if err != nil {
ui.PrintError(os.Stdout, err.Error())
return err
return ui.PrintError(os.Stdout, err.Error())
}

switch key {
case "lang", "language":
if isValidLanguage(value) {
targetCfg.Language = value
} else {
err := fmt.Errorf("invalid language: %s", value)
ui.PrintError(os.Stdout, err.Error())
return err
return ui.PrintError(os.Stdout, fmt.Sprintf("invalid language: %s", value))
}
case "emoji", "use_emoji":
boolVal, parseErr := strconv.ParseBool(value)
if parseErr != nil {
err := fmt.Errorf("invalid boolean value: %s", value)
ui.PrintError(os.Stdout, err.Error())
return err
return ui.PrintError(os.Stdout, fmt.Sprintf("invalid boolean value: %s", value))
}
targetCfg.UseEmoji = boolVal
case "count", "suggestions_count":
intVal, parseErr := strconv.Atoi(value)
if parseErr != nil || intVal < 1 || intVal > 10 {
err := fmt.Errorf("invalid count (must be 1-10): %s", value)
ui.PrintError(os.Stdout, err.Error())
return err
return ui.PrintError(os.Stdout, fmt.Sprintf("invalid count (must be 1-10): %s", value))
}
targetCfg.SuggestionsCount = intVal
case "active-ai", "active_ai":
Expand All @@ -79,9 +71,7 @@ func (c *ConfigCommandFactory) newSetCommand(t *i18n.Translations, cfg *config.C
}
targetCfg.AIConfig.Models[targetCfg.AIConfig.ActiveAI] = config.Model(value)
} else {
err := fmt.Errorf("no active AI provider configured")
ui.PrintError(os.Stdout, err.Error())
return err
return ui.PrintError(os.Stdout, "no active AI provider configured")
}
case "active-vcs", "active_vcs":
targetCfg.ActiveVCSProvider = value
Expand All @@ -90,9 +80,7 @@ func (c *ConfigCommandFactory) newSetCommand(t *i18n.Translations, cfg *config.C
case "git.email", "git-email":
targetCfg.GitFallback.UserEmail = value
default:
err := fmt.Errorf("unknown configuration key: %s", key)
ui.PrintError(os.Stdout, err.Error())
return err
return ui.PrintError(os.Stdout, fmt.Sprintf("unknown configuration key: %s", key))
}

if useLocal {
Expand All @@ -102,8 +90,7 @@ func (c *ConfigCommandFactory) newSetCommand(t *i18n.Translations, cfg *config.C
}

if err != nil {
ui.PrintError(os.Stdout, t.GetMessage("ui_error.error_saving_config", 0, nil))
return err
return ui.PrintError(os.Stdout, t.GetMessage("ui_error.error_saving_config", 0, nil))
}

scope := "global"
Expand Down
9 changes: 3 additions & 6 deletions internal/commands/handler/suggestions.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,7 @@ func (h *SuggestionHandler) handleCommitSelection(ctx context.Context, suggestio
if _, err := fmt.Scan(&input); 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)
return ui.PrintError(os.Stdout, msg)
}

input = strings.TrimSpace(strings.ToLower(input))
Expand All @@ -234,8 +233,7 @@ func (h *SuggestionHandler) handleCommitSelection(ctx context.Context, suggestio
if _, err := fmt.Sscanf(input, "%d", &selection); err != nil || selection < 1 || selection > len(suggestions) {
log.Warn("invalid selection", "input", input, "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)
return ui.PrintError(os.Stdout, msg)
}

log.Info("processing selected commit", "selection", selection)
Expand Down Expand Up @@ -279,8 +277,7 @@ func (h *SuggestionHandler) processCommit(ctx context.Context, suggestion models
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
return ui.PrintError(os.Stdout, h.t.GetMessage("ui_preview.error_editing_message", 0, struct{ Error error }{err}))
}
if editedMessage == "" {
ui.PrintWarning(h.t.GetMessage("ui_preview.commit_cancelled", 0, nil))
Expand Down
3 changes: 1 addition & 2 deletions internal/commands/issues/from_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,7 @@ func (f *IssuesCommandFactory) createFromPlanAction(t *i18n.Translations, cfg *c
Number int
Error string
}{1, err.Error()})
ui.PrintError(os.Stdout, errMsg)
return err
return ui.PrintError(os.Stdout, errMsg)
}

emoji := ""
Expand Down
27 changes: 9 additions & 18 deletions internal/commands/issues/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,15 +177,13 @@ 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))
return ui.PrintError(os.Stdout, 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))
return ui.PrintError(os.Stdout, t.GetMessage("issue.error_multiple_sources", 0, nil))
}

ui.PrintSectionBanner(t.GetMessage("issue.banner", 0, nil))
Expand All @@ -195,8 +193,7 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c
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
return ui.PrintError(os.Stdout, fmt.Sprintf("%s: %v", t.GetMessage("issue.error_generating", 0, nil), err))
}

var spinnerMsg string
Expand Down Expand Up @@ -229,8 +226,7 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c
"from_diff", fromDiff,
"from_pr", fromPR,
"duration_ms", time.Since(start).Milliseconds())
ui.HandleAppError(err)
return err
return ui.HandleAppError(err)
}

log.Debug("issue generated",
Expand Down Expand Up @@ -283,8 +279,7 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c
log.Error("failed to create issue",
"error", err,
"duration_ms", time.Since(start).Milliseconds())
ui.HandleAppError(err)
return err
return ui.HandleAppError(err)
}

log.Info("issue created successfully",
Expand Down Expand Up @@ -394,16 +389,14 @@ func (f *IssuesCommandFactory) createLinkAction(t *i18n.Translations, _ *config.
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")
return ui.PrintError(os.Stdout, t.GetMessage("issue.error_invalid_pr", 0, nil))
}

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")
return ui.PrintError(os.Stdout, t.GetMessage("issue.error_invalid_issue", 0, nil))
}

ui.PrintSectionBanner(t.GetMessage("issue.link_banner", 0, nil))
Expand All @@ -413,8 +406,7 @@ func (f *IssuesCommandFactory) createLinkAction(t *i18n.Translations, _ *config.
log.Error("failed to create issue service",
"error", err,
"duration_ms", time.Since(start).Milliseconds())
ui.HandleAppError(err)
return err
return ui.HandleAppError(err)
}

spinner := ui.NewSmartSpinner(t.GetMessage("issue.linking", 0, struct {
Expand All @@ -432,8 +424,7 @@ func (f *IssuesCommandFactory) createLinkAction(t *i18n.Translations, _ *config.
"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
return ui.PrintError(os.Stdout, fmt.Sprintf("%s: %v", t.GetMessage("issue.error_linking", 0, nil), err))
}

log.Info("issue linked to PR successfully",
Expand Down
6 changes: 2 additions & 4 deletions internal/commands/issues/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ func (f *IssuesCommandFactory) newTemplateCommand(t *i18n.Translations, _ *confi
ui.PrintInfo(t.GetMessage("issue.template_init_info", 0, nil))

if err := templateService.InitializeTemplates(ctx, force); err != nil {
ui.HandleAppError(err, t)
return err
return ui.HandleAppError(err, t)
}

templatesDir, _ := templateService.GetTemplatesDir(ctx)
Expand All @@ -54,8 +53,7 @@ func (f *IssuesCommandFactory) newTemplateCommand(t *i18n.Translations, _ *confi
Action: func(ctx context.Context, cmd *cli.Command) error {
templates, err := templateService.ListTemplates(ctx)
if err != nil {
ui.HandleAppError(err, t)
return err
return ui.HandleAppError(err, t)
}

if len(templates) == 0 {
Expand Down
4 changes: 2 additions & 2 deletions internal/commands/pull_requests/summarize.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ func (c *SummarizeCommand) CreateCommand(t *i18n.Translations, _ *cfg.Config) *c
"pr_number", prNumber,
"duration_ms", time.Since(start).Milliseconds())
spinner.Error(t.GetMessage("ui.error_generating_pr_summary", 0, nil))
ui.HandleAppError(err)
return fmt.Errorf(t.GetMessage("error.pr_summary_error", 0, nil)+": %w", err)
_ = ui.HandleAppError(err)
return ui.Shown(fmt.Errorf(t.GetMessage("error.pr_summary_error", 0, nil)+": %w", err))
}

log.Info("PR summarized successfully",
Expand Down
3 changes: 1 addition & 2 deletions internal/commands/release/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ func editReleaseAction(releaseSvc releaseService, gitSvc gitService, trans *i18n

existingRelease, err := releaseSvc.GetRelease(ctx, version)
if err != nil {
ui.HandleAppError(err)
return fmt.Errorf("%s", trans.GetMessage("release.error_fetching_release", 0, struct{ Error string }{err.Error()}))
return ui.HandleAppError(err)
}

content := existingRelease.Body
Expand Down
Loading
Loading