diff --git a/Makefile b/Makefile index 61f987d..09c70d1 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,11 @@ tools: $(GO) install golang.org/x/vuln/cmd/govulncheck@latest @echo "Tools installed to \"$$(go env GOPATH)/bin\". Ensure it is on your PATH." +.PHONY: cyclo +cyclo: + @command -v gocyclo >/dev/null 2>&1 || { echo "Installing gocyclo..."; $(GO) install github.com/fzipp/gocyclo/cmd/gocyclo@latest; } + gocyclo -over 15 ./... + check: fmt vet lint test ci: tidy check vuln diff --git a/README.md b/README.md index cda8835..dc0f590 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ A powerful command-line tool that bridges the gap between local Markdown files and Jira, enabling seamless story and task management with bidirectional synchronization. [![CI](https://github.com/karolswdev/ticketr/actions/workflows/ci.yml/badge.svg)](https://github.com/karolswdev/ticketr/actions/workflows/ci.yml) -[![Go Version](https://img.shields.io/badge/Go-1.22%2B-00ADD8?style=flat&logo=go)](https://go.dev) +[![Go Version](https://img.shields.io/badge/Go-1.24%2B-00ADD8?style=flat&logo=go)](https://go.dev) [![PkgGoDev](https://pkg.go.dev/badge/github.com/karolswdev/ticketr)](https://pkg.go.dev/github.com/karolswdev/ticketr) -[![Go Report Card](https://goreportcard.com/badge/github.com/karolswdev/ticketr)](https://goreportcard.com/report/github.com/karolswdev/ticketr) +[![Go Report Card](https://goreportcard.com/badge/github.com/karolswdev/ticketr?refresh=1)](https://goreportcard.com/report/github.com/karolswdev/ticketr) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?style=flat&logo=docker)](Dockerfile) @@ -317,11 +317,7 @@ Ticketr maintains a `.ticketr.state` file to track content hashes and support in ticketr pull --strategy=local-wins ``` -<<<<<<< HEAD Push is now state-aware by default. The CLI uses the `PushService` so unchanged tickets are skipped. In `--dry-run` mode, Ticketr shows all intended operations without writing to JIRA or the file/state. -======= -Note: State-aware skipping for `push` exists in the `PushService`, but the default CLI path currently uses `TicketService` (always processes all tickets). A future release will wire `push` to the state-aware flow. Until then, all tickets are processed during `push`. ->>>>>>> origin/main The `.ticketr.state` file is environment-specific and ignored by default via `.gitignore`. diff --git a/internal/adapters/filesystem/file_repository.go b/internal/adapters/filesystem/file_repository.go index 985d196..f7c0e20 100644 --- a/internal/adapters/filesystem/file_repository.go +++ b/internal/adapters/filesystem/file_repository.go @@ -28,148 +28,88 @@ func (r *FileRepository) GetTickets(filepath string) ([]domain.Ticket, error) { // SaveTickets writes tickets to a file in the new TICKET format func (r *FileRepository) SaveTickets(filepath string, tickets []domain.Ticket) error { - file, err := os.Create(filepath) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer func() { _ = file.Close() }() - - writer := bufio.NewWriter(file) - w := func(n int, err error) error { - if err != nil { - return err - } - return nil - } - - for i, ticket := range tickets { - // Write ticket heading with Jira ID if present - if ticket.JiraID != "" { - if err := w(fmt.Fprintf(writer, "# TICKET: [%s] %s\n", ticket.JiraID, ticket.Title)); err != nil { - return err - } - } else { - if err := w(fmt.Fprintf(writer, "# TICKET: %s\n", ticket.Title)); err != nil { - return err - } - } - if err := w(fmt.Fprintln(writer)); err != nil { - return err - } - - // Write description - if ticket.Description != "" { - if err := w(fmt.Fprintln(writer, "## Description")); err != nil { - return err - } - if err := w(fmt.Fprintln(writer, ticket.Description)); err != nil { - return err - } - if err := w(fmt.Fprintln(writer)); err != nil { - return err - } - } - - // Write fields - if len(ticket.CustomFields) > 0 { - if err := w(fmt.Fprintln(writer, "## Fields")); err != nil { - return err - } - for key, value := range ticket.CustomFields { - if err := w(fmt.Fprintf(writer, "%s: %s\n", key, value)); err != nil { - return err - } - } - if err := w(fmt.Fprintln(writer)); err != nil { - return err - } - } - - // Write acceptance criteria - if len(ticket.AcceptanceCriteria) > 0 { - if err := w(fmt.Fprintln(writer, "## Acceptance Criteria")); err != nil { - return err - } - for _, ac := range ticket.AcceptanceCriteria { - if err := w(fmt.Fprintf(writer, "- %s\n", ac)); err != nil { - return err - } - } - if err := w(fmt.Fprintln(writer)); err != nil { - return err - } - } - - // Write tasks - if len(ticket.Tasks) > 0 { - if err := w(fmt.Fprintln(writer, "## Tasks")); err != nil { - return err - } - for _, task := range ticket.Tasks { - // Write task with Jira ID if present - if task.JiraID != "" { - if err := w(fmt.Fprintf(writer, "- [%s] %s\n", task.JiraID, task.Title)); err != nil { - return err - } - } else { - if err := w(fmt.Fprintf(writer, "- %s\n", task.Title)); err != nil { - return err - } - } - - // Write task description (indented) - if task.Description != "" { - if err := w(fmt.Fprintln(writer, " ## Description")); err != nil { - return err - } - // Indent description lines - if err := w(fmt.Fprintf(writer, " %s\n", task.Description)); err != nil { - return err - } - if err := w(fmt.Fprintln(writer)); err != nil { - return err - } - } - - // Write task fields (indented) - if len(task.CustomFields) > 0 { - if err := w(fmt.Fprintln(writer, " ## Fields")); err != nil { - return err - } - for key, value := range task.CustomFields { - if err := w(fmt.Fprintf(writer, " %s: %s\n", key, value)); err != nil { - return err - } - } - if err := w(fmt.Fprintln(writer)); err != nil { - return err - } - } - - // Write task acceptance criteria (indented) - if len(task.AcceptanceCriteria) > 0 { - if err := w(fmt.Fprintln(writer, " ## Acceptance Criteria")); err != nil { - return err - } - for _, ac := range task.AcceptanceCriteria { - if err := w(fmt.Fprintf(writer, " - %s\n", ac)); err != nil { - return err - } - } - if err := w(fmt.Fprintln(writer)); err != nil { - return err - } - } - } - } - - // Add spacing between tickets - if i < len(tickets)-1 { - if err := w(fmt.Fprintln(writer)); err != nil { - return err - } - } - } - - return writer.Flush() + file, err := os.Create(filepath) + if err != nil { return fmt.Errorf("failed to create file: %w", err) } + defer func() { _ = file.Close() }() + + writer := bufio.NewWriter(file) + w := func(n int, err error) error { if err != nil { return err }; return nil } + + writeHeader := func(t domain.Ticket) error { + if t.JiraID != "" { + return w(fmt.Fprintf(writer, "# TICKET: [%s] %s\n", t.JiraID, t.Title)) + } + return w(fmt.Fprintf(writer, "# TICKET: %s\n", t.Title)) + } + writeDescription := func(desc string) error { + if desc == "" { return nil } + if err := w(fmt.Fprintln(writer, "## Description")); err != nil { return err } + if err := w(fmt.Fprintln(writer, desc)); err != nil { return err } + return w(fmt.Fprintln(writer)) + } + writeFields := func(fields map[string]string) error { + if len(fields) == 0 { return nil } + if err := w(fmt.Fprintln(writer, "## Fields")); err != nil { return err } + for k, v := range fields { + if err := w(fmt.Fprintf(writer, "%s: %s\n", k, v)); err != nil { return err } + } + return w(fmt.Fprintln(writer)) + } + writeAcceptance := func(criteria []string) error { + if len(criteria) == 0 { return nil } + if err := w(fmt.Fprintln(writer, "## Acceptance Criteria")); err != nil { return err } + for _, ac := range criteria { + if err := w(fmt.Fprintf(writer, "- %s\n", ac)); err != nil { return err } + } + return w(fmt.Fprintln(writer)) + } + writeTask := func(t domain.Task) error { + if t.JiraID != "" { + if err := w(fmt.Fprintf(writer, "- [%s] %s\n", t.JiraID, t.Title)); err != nil { return err } + } else { + if err := w(fmt.Fprintf(writer, "- %s\n", t.Title)); err != nil { return err } + } + if t.Description != "" { + if err := w(fmt.Fprintln(writer, " ## Description")); err != nil { return err } + if err := w(fmt.Fprintf(writer, " %s\n", t.Description)); err != nil { return err } + if err := w(fmt.Fprintln(writer)); err != nil { return err } + } + if len(t.CustomFields) > 0 { + if err := w(fmt.Fprintln(writer, " ## Fields")); err != nil { return err } + for k, v := range t.CustomFields { + if err := w(fmt.Fprintf(writer, " %s: %s\n", k, v)); err != nil { return err } + } + if err := w(fmt.Fprintln(writer)); err != nil { return err } + } + if len(t.AcceptanceCriteria) > 0 { + if err := w(fmt.Fprintln(writer, " ## Acceptance Criteria")); err != nil { return err } + for _, ac := range t.AcceptanceCriteria { + if err := w(fmt.Fprintf(writer, " - %s\n", ac)); err != nil { return err } + } + if err := w(fmt.Fprintln(writer)); err != nil { return err } + } + return nil + } + writeTasks := func(tasks []domain.Task) error { + if len(tasks) == 0 { return nil } + if err := w(fmt.Fprintln(writer, "## Tasks")); err != nil { return err } + for _, t := range tasks { + if err := writeTask(t); err != nil { return err } + } + return nil + } + + for i, ticket := range tickets { + if err := writeHeader(ticket); err != nil { return err } + if err := w(fmt.Fprintln(writer)); err != nil { return err } + if err := writeDescription(ticket.Description); err != nil { return err } + if err := writeFields(ticket.CustomFields); err != nil { return err } + if err := writeAcceptance(ticket.AcceptanceCriteria); err != nil { return err } + if err := writeTasks(ticket.Tasks); err != nil { return err } + + if i < len(tickets)-1 { + if err := w(fmt.Fprintln(writer)); err != nil { return err } + } + } + return writer.Flush() } diff --git a/internal/analytics/analyzer.go b/internal/analytics/analyzer.go index d0e8136..622636e 100644 --- a/internal/analytics/analyzer.go +++ b/internal/analytics/analyzer.go @@ -192,137 +192,104 @@ func (a *Analyzer) extractStoryPoints(ticket domain.Ticket) float64 { // Returns: // - string: A formatted report suitable for console output func (a *Analyzer) FormatReport(stats *Statistics) string { - var report strings.Builder - - report.WriteString("\n╔══════════════════════════════════════╗\n") - report.WriteString("║ TICKET ANALYTICS REPORT ║\n") - report.WriteString("╚══════════════════════════════════════╝\n\n") - - // Overall Statistics - report.WriteString("📊 Overall Statistics\n") - report.WriteString("────────────────────\n") - report.WriteString(fmt.Sprintf(" Total Tickets: %d\n", stats.TotalTickets)) - report.WriteString(fmt.Sprintf(" Total Tasks: %d\n", stats.TotalTasks)) - report.WriteString(fmt.Sprintf(" Total Items: %d\n", stats.TotalTickets+stats.TotalTasks)) - if stats.TotalStoryPoints > 0 { - report.WriteString(fmt.Sprintf(" Total Story Points: %.1f\n", stats.TotalStoryPoints)) - } - report.WriteString(fmt.Sprintf(" Acceptance Criteria: %d\n", stats.AcceptanceCriteriaCount)) - report.WriteString("\n") - - // JIRA Sync Status - report.WriteString("🔄 JIRA Synchronization\n") - report.WriteString("────────────────────\n") - ticketSyncPercent := 0 - if stats.TotalTickets > 0 { - ticketSyncPercent = (stats.TicketsWithJiraID * 100) / stats.TotalTickets - } - taskSyncPercent := 0 - if stats.TotalTasks > 0 { - taskSyncPercent = (stats.TasksWithJiraID * 100) / stats.TotalTasks - } - report.WriteString(fmt.Sprintf(" Tickets Synced: %d/%d (%d%%)\n", - stats.TicketsWithJiraID, stats.TotalTickets, ticketSyncPercent)) - report.WriteString(fmt.Sprintf(" Tasks Synced: %d/%d (%d%%)\n", - stats.TasksWithJiraID, stats.TotalTasks, taskSyncPercent)) - report.WriteString("\n") - - // Tickets by Type - if len(stats.TicketsByType) > 0 { - report.WriteString("📋 Tickets by Type\n") - report.WriteString("────────────────────\n") - for ticketType, count := range stats.TicketsByType { - bar := a.makeBar(count, stats.TotalTickets, 20) - report.WriteString(fmt.Sprintf(" %-10s %s %d\n", ticketType+":", bar, count)) - } - report.WriteString("\n") - } - - // Tickets by Status - if len(stats.TicketsByStatus) > 0 { - report.WriteString("📈 Tickets by Status\n") - report.WriteString("────────────────────\n") - // Order: Done, In Progress, In Review, Open, To Do - statusOrder := []string{"Done", "In Progress", "In Review", "Open", "To Do"} - for _, status := range statusOrder { - if count, exists := stats.TicketsByStatus[status]; exists { - bar := a.makeBar(count, stats.TotalTickets, 20) - report.WriteString(fmt.Sprintf(" %-12s %s %d\n", status+":", bar, count)) - } - } - // Add any other statuses not in the standard order - for status, count := range stats.TicketsByStatus { - found := false - for _, orderedStatus := range statusOrder { - if status == orderedStatus { - found = true - break - } - } - if !found { - bar := a.makeBar(count, stats.TotalTickets, 20) - report.WriteString(fmt.Sprintf(" %-12s %s %d\n", status+":", bar, count)) - } - } - report.WriteString("\n") - } + var b strings.Builder + writeHeader(&b) + writeOverall(&b, stats) + writeSync(&b, stats) + writeTicketsByType(&b, a, stats) + writeTicketsByStatus(&b, a, stats) + writeTasksByStatus(&b, a, stats) + writeProgress(&b, stats) + return b.String() +} - // Tasks by Status - if len(stats.TasksByStatus) > 0 && stats.TotalTasks > 0 { - report.WriteString("✅ Tasks by Status\n") - report.WriteString("────────────────────\n") - statusOrder := []string{"Done", "In Progress", "In Review", "Open", "To Do"} - for _, status := range statusOrder { - if count, exists := stats.TasksByStatus[status]; exists { - bar := a.makeBar(count, stats.TotalTasks, 20) - report.WriteString(fmt.Sprintf(" %-12s %s %d\n", status+":", bar, count)) - } - } - // Add any other statuses - for status, count := range stats.TasksByStatus { - found := false - for _, orderedStatus := range statusOrder { - if status == orderedStatus { - found = true - break - } - } - if !found { - bar := a.makeBar(count, stats.TotalTasks, 20) - report.WriteString(fmt.Sprintf(" %-12s %s %d\n", status+":", bar, count)) - } - } - report.WriteString("\n") - } +func writeHeader(b *strings.Builder) { + b.WriteString("\n╔══════════════════════════════════════╗\n") + b.WriteString("║ TICKET ANALYTICS REPORT ║\n") + b.WriteString("╚══════════════════════════════════════╝\n\n") +} - // Progress Summary - report.WriteString("🎯 Progress Summary\n") - report.WriteString("────────────────────\n") +func writeOverall(b *strings.Builder, s *Statistics) { + b.WriteString("📊 Overall Statistics\n") + b.WriteString("────────────────────\n") + b.WriteString(fmt.Sprintf(" Total Tickets: %d\n", s.TotalTickets)) + b.WriteString(fmt.Sprintf(" Total Tasks: %d\n", s.TotalTasks)) + b.WriteString(fmt.Sprintf(" Total Items: %d\n", s.TotalTickets+s.TotalTasks)) + if s.TotalStoryPoints > 0 { + b.WriteString(fmt.Sprintf(" Total Story Points: %.1f\n", s.TotalStoryPoints)) + } + b.WriteString(fmt.Sprintf(" Acceptance Criteria: %d\n\n", s.AcceptanceCriteriaCount)) +} - // Calculate completion percentage - doneTickets := stats.TicketsByStatus["Done"] - doneTasks := stats.TasksByStatus["Done"] - totalItems := stats.TotalTickets + stats.TotalTasks - doneItems := doneTickets + doneTasks +func writeSync(b *strings.Builder, s *Statistics) { + b.WriteString("🔄 JIRA Synchronization\n") + b.WriteString("────────────────────\n") + tpct := 0; if s.TotalTickets > 0 { tpct = (s.TicketsWithJiraID*100)/s.TotalTickets } + apct := 0; if s.TotalTasks > 0 { apct = (s.TasksWithJiraID*100)/s.TotalTasks } + b.WriteString(fmt.Sprintf(" Tickets Synced: %d/%d (%d%%)\n", s.TicketsWithJiraID, s.TotalTickets, tpct)) + b.WriteString(fmt.Sprintf(" Tasks Synced: %d/%d (%d%%)\n\n", s.TasksWithJiraID, s.TotalTasks, apct)) +} - completionPercent := 0 - if totalItems > 0 { - completionPercent = (doneItems * 100) / totalItems - } +func writeTicketsByType(b *strings.Builder, a *Analyzer, s *Statistics) { + if len(s.TicketsByType) == 0 { return } + b.WriteString("📋 Tickets by Type\n") + b.WriteString("────────────────────\n") + for t, c := range s.TicketsByType { + bar := a.makeBar(c, s.TotalTickets, 20) + b.WriteString(fmt.Sprintf(" %-10s %s %d\n", t+":", bar, c)) + } + b.WriteString("\n") +} - report.WriteString(fmt.Sprintf(" Overall Completion: %d%%\n", completionPercent)) - report.WriteString(fmt.Sprintf(" Items Completed: %d/%d\n", doneItems, totalItems)) +func writeOrderedBars(b *strings.Builder, a *Analyzer, total int, order []string, m map[string]int) { + for _, k := range order { + if c, ok := m[k]; ok { + bar := a.makeBar(c, total, 20) + b.WriteString(fmt.Sprintf(" %-12s %s %d\n", k+":", bar, c)) + } + } + for k, c := range m { + found := false + for _, okk := range order { if k == okk { found = true; break } } + if !found { + bar := a.makeBar(c, total, 20) + b.WriteString(fmt.Sprintf(" %-12s %s %d\n", k+":", bar, c)) + } + } + b.WriteString("\n") +} - if stats.TotalStoryPoints > 0 { - // Estimate completed story points (simplified - assumes even distribution) - completedPoints := (stats.TotalStoryPoints * float64(doneTickets)) / float64(stats.TotalTickets) - report.WriteString(fmt.Sprintf(" Points Completed: %.1f/%.1f\n", - completedPoints, stats.TotalStoryPoints)) - } +func writeTicketsByStatus(b *strings.Builder, a *Analyzer, s *Statistics) { + if len(s.TicketsByStatus) == 0 { return } + b.WriteString("📈 Tickets by Status\n") + b.WriteString("────────────────────\n") + order := []string{"Done", "In Progress", "In Review", "Open", "To Do"} + writeOrderedBars(b, a, s.TotalTickets, order, s.TicketsByStatus) +} - report.WriteString("\n────────────────────────────────────────\n") +func writeTasksByStatus(b *strings.Builder, a *Analyzer, s *Statistics) { + if len(s.TasksByStatus) == 0 || s.TotalTasks == 0 { return } + b.WriteString("✅ Tasks by Status\n") + b.WriteString("────────────────────\n") + order := []string{"Done", "In Progress", "In Review", "Open", "To Do"} + writeOrderedBars(b, a, s.TotalTasks, order, s.TasksByStatus) +} - return report.String() +func writeProgress(b *strings.Builder, s *Statistics) { + b.WriteString("🎯 Progress Summary\n") + b.WriteString("────────────────────\n") + doneTickets := s.TicketsByStatus["Done"] + doneTasks := s.TasksByStatus["Done"] + total := s.TotalTickets + s.TotalTasks + done := doneTickets + doneTasks + pct := 0; if total > 0 { pct = (done*100)/total } + b.WriteString(fmt.Sprintf(" Overall Completion: %d%%\n", pct)) + b.WriteString(fmt.Sprintf(" Items Completed: %d/%d\n", done, total)) + if s.TotalStoryPoints > 0 && s.TotalTickets > 0 { + completed := (s.TotalStoryPoints * float64(doneTickets)) / float64(s.TotalTickets) + b.WriteString(fmt.Sprintf(" Points Completed: %.1f/%.1f\n", completed, s.TotalStoryPoints)) + } + b.WriteString("\n────────────────────────────────────────\n") } // makeBar creates a simple text progress bar diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index 12e941a..77d7887 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -45,86 +45,80 @@ func getDefaultFieldMappings() map[string]interface{} { // Render converts a domain.Ticket to Markdown format func (r *Renderer) Render(ticket domain.Ticket) string { - var sb strings.Builder + var sb strings.Builder - // Title with JIRA ID if present - if ticket.JiraID != "" { - sb.WriteString(fmt.Sprintf("# TICKET: [%s] %s\n", ticket.JiraID, ticket.Title)) - } else { - sb.WriteString(fmt.Sprintf("# TICKET: %s\n", ticket.Title)) - } - sb.WriteString("\n") - - // Custom fields section (excluding Type which is handled differently in some cases) - hasCustomFields := false - for fieldName, fieldValue := range ticket.CustomFields { - if fieldName != "Type" && fieldName != "Parent" && fieldValue != "" { - if !hasCustomFields { - sb.WriteString("## Fields\n") - hasCustomFields = true - } - sb.WriteString(fmt.Sprintf("- %s: %s\n", fieldName, fieldValue)) - } - } - if hasCustomFields { - sb.WriteString("\n") - } + writeTitle(&sb, ticket) + appendCustomFields(&sb, ticket.CustomFields) + appendDescription(&sb, ticket.Description) + appendAcceptance(&sb, ticket.AcceptanceCriteria) + appendTasks(&sb, ticket.Tasks) - // Description section - if ticket.Description != "" { - sb.WriteString("## Description\n") - sb.WriteString(ticket.Description) - sb.WriteString("\n\n") - } + return sb.String() +} - // Acceptance Criteria section - if len(ticket.AcceptanceCriteria) > 0 { - sb.WriteString("## Acceptance Criteria\n") - for _, criterion := range ticket.AcceptanceCriteria { - sb.WriteString(fmt.Sprintf("- %s\n", criterion)) - } - sb.WriteString("\n") - } +func writeTitle(sb *strings.Builder, t domain.Ticket) { + if t.JiraID != "" { + sb.WriteString(fmt.Sprintf("# TICKET: [%s] %s\n\n", t.JiraID, t.Title)) + return + } + sb.WriteString(fmt.Sprintf("# TICKET: %s\n\n", t.Title)) +} - // Tasks section - if len(ticket.Tasks) > 0 { - sb.WriteString("## Tasks\n") - for _, task := range ticket.Tasks { - if task.JiraID != "" { - sb.WriteString(fmt.Sprintf("- [%s] %s\n", task.JiraID, task.Title)) - } else { - sb.WriteString(fmt.Sprintf("- %s\n", task.Title)) - } - - // Task custom fields (indented) - for fieldName, fieldValue := range task.CustomFields { - if fieldValue != "" { - sb.WriteString(fmt.Sprintf(" - %s: %s\n", fieldName, fieldValue)) - } - } - - // Task description (indented) - if task.Description != "" { - lines := strings.Split(task.Description, "\n") - for _, line := range lines { - if line != "" { - sb.WriteString(fmt.Sprintf(" %s\n", line)) - } - } - } - - // Task acceptance criteria (indented) - if len(task.AcceptanceCriteria) > 0 { - sb.WriteString(" ### Acceptance Criteria\n") - for _, criterion := range task.AcceptanceCriteria { - sb.WriteString(fmt.Sprintf(" - %s\n", criterion)) - } - } - } - sb.WriteString("\n") - } +func appendCustomFields(sb *strings.Builder, fields map[string]string) { + has := false + for name, val := range fields { + if name != "Type" && name != "Parent" && val != "" { + if !has { sb.WriteString("## Fields\n"); has = true } + sb.WriteString(fmt.Sprintf("- %s: %s\n", name, val)) + } + } + if has { sb.WriteString("\n") } +} - return sb.String() +func appendDescription(sb *strings.Builder, desc string) { + if desc == "" { return } + sb.WriteString("## Description\n") + sb.WriteString(desc) + sb.WriteString("\n\n") +} + + +func appendAcceptance(sb *strings.Builder, ac []string) { + if len(ac) == 0 { return } + sb.WriteString("## Acceptance Criteria\n") + for _, c := range ac { + sb.WriteString(fmt.Sprintf("- %s\n", c)) + } + sb.WriteString("\n") +} + +func appendTasks(sb *strings.Builder, tasks []domain.Task) { + if len(tasks) == 0 { return } + sb.WriteString("## Tasks\n") + for _, t := range tasks { + if t.JiraID != "" { + sb.WriteString(fmt.Sprintf("- [%s] %s\n", t.JiraID, t.Title)) + } else { + sb.WriteString(fmt.Sprintf("- %s\n", t.Title)) + } + for name, val := range t.CustomFields { + if val != "" { + sb.WriteString(fmt.Sprintf(" - %s: %s\n", name, val)) + } + } + if t.Description != "" { + for _, line := range strings.Split(t.Description, "\n") { + if line != "" { sb.WriteString(fmt.Sprintf(" %s\n", line)) } + } + } + if len(t.AcceptanceCriteria) > 0 { + sb.WriteString(" ### Acceptance Criteria\n") + for _, c := range t.AcceptanceCriteria { + sb.WriteString(fmt.Sprintf(" - %s\n", c)) + } + } + } + sb.WriteString("\n") } // RenderMultiple renders multiple tickets to a single Markdown document