CLI/TUI for drafting, repeating, and publishing daily standup updates as GitHub issues
github go cli golang management project tui daily
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add remote issue template support with dynamic TUI fields

- Configure issue_template in pad.toml (default: .github/ISSUE_TEMPLATE/daily-update.yml)
- Fetch and parse issue templates remotely from GitHub
- Generate TUI fields dynamically from template structure
- Embed hidden field ID markers (<!-- pad:id:... -->) in issue bodies
- Handle template changes: carry over orphaned fields to synthetic section
- Add loading spinners for all remote operations
- Generate issue titles from template title field with date placeholders
- Support date formats: YYYY/MM/DD, YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY
- Fallback to legacy title format when template has no title
- Update bundled report workflow to parse by field IDs

+1510 -425
+77 -12
.github/scripts/daily_update_workflow.py
··· 294 294 if not body: 295 295 return sections 296 296 297 - sections["yesterday"] = extract_section( 297 + field_sections = split_sections_by_field_id(body) 298 + 299 + sections["yesterday"] = extract_field_section( 298 300 body, 299 - [ 301 + field_sections=field_sections, 302 + field_id="yesterday", 303 + patterns=[ 300 304 r"### ✅ What did you do yesterday\?\s*([\s\S]*?)(?=###|##|$)", 301 305 r"## ✅ What did you do yesterday\?\s*([\s\S]*?)(?=##|$)", 302 306 ], 303 307 ) 304 - sections["today"] = extract_section( 308 + sections["today"] = extract_field_section( 305 309 body, 306 - [ 310 + field_sections=field_sections, 311 + field_id="today", 312 + patterns=[ 307 313 r"### 🎯 What will you do today\?\s*([\s\S]*?)(?=###|##|$)", 308 314 r"## 🎯 What will you do today\?\s*([\s\S]*?)(?=##|$)", 309 315 ], 310 316 ) 311 - sections["blockers"] = extract_section( 317 + sections["blockers"] = extract_field_section( 312 318 body, 313 - [ 319 + field_sections=field_sections, 320 + field_id="blockers", 321 + patterns=[ 314 322 r"### 🚧 Any blockers\?\s*([\s\S]*?)(?=###|##|$)", 315 323 r"## 🚧 Any blockers\?\s*([\s\S]*?)(?=##|$)", 316 324 ], 317 325 clean_empty=True, 318 326 ) 319 - sections["parking_lot_details"] = extract_section( 327 + sections["parking_lot_details"] = extract_field_section( 320 328 body, 321 - [ 329 + field_sections=field_sections, 330 + field_id="parking_details", 331 + patterns=[ 322 332 r"### 📝 Parking Lot Details\s*([\s\S]*?)(?=###|##|$)", 323 333 r"## 📝 Parking Lot Details\s*([\s\S]*?)(?=##|$)", 324 334 ], 325 335 clean_empty=True, 326 336 ) 327 - sections["additional_comments"] = extract_section( 337 + sections["additional_comments"] = extract_field_section( 328 338 body, 329 - [ 339 + field_sections=field_sections, 340 + field_id="comments", 341 + patterns=[ 330 342 r"### 💬 Additional Comments\s*([\s\S]*?)(?=###|##|$)", 331 343 r"## 💬 Additional Comments\s*([\s\S]*?)(?=##|$)", 332 344 ], 333 345 clean_empty=True, 334 346 ) 347 + 348 + parking_section = field_sections.get("parking_lot", "") or extract_section( 349 + body, 350 + [ 351 + r"### 🚨 Do you request a Parking Lot or escalation\?\s*([\s\S]*?)(?=###|##|$)", 352 + r"## 🚨 Do you request a Parking Lot or escalation\?\s*([\s\S]*?)(?=##|$)", 353 + r"### 🚨 Parking Lot / Escalation\s*([\s\S]*?)(?=###|##|$)", 354 + r"## 🚨 Parking Lot / Escalation\s*([\s\S]*?)(?=##|$)", 355 + ], 356 + ) 335 357 sections["parking_lot"] = bool( 336 - re.search(r"- \[x\].*Parking Lot", body, flags=re.IGNORECASE) 337 - or re.search(r"- ✅ Yes, I need a Parking Lot", body, flags=re.IGNORECASE) 358 + re.search(r"^- \[x\]", parking_section, flags=re.IGNORECASE | re.MULTILINE) 359 + or re.search( 360 + r"- ✅ Yes, I need a Parking Lot", parking_section, flags=re.IGNORECASE 361 + ) 362 + or sections["parking_lot_details"] 338 363 ) 364 + return sections 365 + 366 + 367 + def extract_field_section( 368 + body: str, 369 + *, 370 + field_sections: dict[str, str], 371 + field_id: str, 372 + patterns: list[str], 373 + clean_empty: bool = False, 374 + ) -> str: 375 + value = field_sections.get(field_id, "") 376 + if value: 377 + return clean_empty_response(value) if clean_empty else value 378 + return extract_section(body, patterns, clean_empty=clean_empty) 379 + 380 + 381 + def split_sections_by_field_id(body: str) -> dict[str, str]: 382 + sections: dict[str, str] = {} 383 + current_id = "" 384 + current_lines: list[str] = [] 385 + heading_pattern = re.compile( 386 + r"^#{2,6} .*?<!--\s*pad:id:([A-Za-z0-9._-]+)\s*-->\s*$" 387 + ) 388 + 389 + for line in body.splitlines(): 390 + match = heading_pattern.match(line.strip()) 391 + if match: 392 + if current_id: 393 + sections[current_id] = "\n".join(current_lines).strip() 394 + current_id = match.group(1) 395 + current_lines = [] 396 + continue 397 + 398 + if current_id: 399 + current_lines.append(line) 400 + 401 + if current_id: 402 + sections[current_id] = "\n".join(current_lines).strip() 403 + 339 404 return sections 340 405 341 406
+3
AGENTS.md
··· 29 29 - 2026-04-16: Go's `os.UserConfigDir()` does not honor `XDG_CONFIG_HOME` on macOS. Keep `pad` on explicit env-aware path helpers so tests and local overrides can isolate config/data cleanly. 30 30 - 2026-04-16: `gh` authentication depends on its normal config home. If tests or wrappers override `XDG_CONFIG_HOME`, remote commands like `pad list` and remote `pad show` will not see existing `gh auth login` state unless that config is also made available. 31 31 - 2026-04-16: `pad repeat` now pre-fills from the latest GitHub daily update issue by the authenticated user instead of reading local JSON entries. 32 + - 2026-04-16: Daily issue bodies now include hidden `<!-- pad:id:... -->` markers on section headings so `pad repeat` and the bundled report workflow can survive template label changes as long as field IDs stay stable. 33 + - 2026-04-16: Remote operations (loading templates, fetching issues, checking for duplicates) now show a loading spinner using `charmbracelet/bubbles`. Falls back gracefully in non-terminal environments. 34 + - 2026-04-16: Issue titles are now generated from the template's `title` field, with date placeholder replacement (YYYY/MM/DD, YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY). Falls back to legacy `[Daily Update] [YYYY/MM/DD]` format if the template has no title.
+8 -1
README.md
··· 58 58 ```toml 59 59 github_repo = "owner/repo" 60 60 labels = ["daily-update"] 61 + issue_template = ".github/ISSUE_TEMPLATE/daily-update.yml" 61 62 ``` 62 63 63 64 Configure your repository: 64 65 65 66 ```bash 66 67 ./pad init --repo owner/repo --labels daily-update 68 + ``` 69 + 70 + Use a different remote issue template path: 71 + 72 + ```bash 73 + ./pad init --repo owner/repo --labels daily-update --issue-template .github/ISSUE_TEMPLATE/team-standup.yml 67 74 ``` 68 75 69 76 ## Repository Setup ··· 80 87 81 88 - [`.github/ISSUE_TEMPLATE/daily-update.yml`](.github/ISSUE_TEMPLATE/daily-update.yml) 82 89 83 - Place it in your repository at the same path. This template matches the structure that `pad` currently expects when parsing and creating issues. 90 + Place it in your repository at the same path, or configure a different path with `issue_template` in `pad.toml`. `pad` fetches the template from GitHub before opening the TUI, builds the editor fields dynamically from the template, and reuses stable field IDs when repeating older entries. 84 91 85 92 ### 3. (Optional) Set Up Automated Workflows 86 93
+6 -1
cmd/create.go
··· 29 29 } 30 30 31 31 ctx := context.Background() 32 + template, err := loadIssueTemplate(ctx, env) 33 + if err != nil { 34 + return err 35 + } 36 + 32 37 if !dryRun { 33 38 if err := ensureCanCreateForDate(ctx, env, resolvedDate); err != nil { 34 39 return err 35 40 } 36 41 } 37 42 38 - entry := daily.New(resolvedDate) 43 + entry := daily.New(resolvedDate, template) 39 44 40 45 if dryRun { 41 46 entry, err = tui.Edit(entry)
+7
cmd/init.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "strings" 5 6 6 7 "github.com/spf13/cobra" 7 8 "github.com/vieitesss/pad/internal/appfs" ··· 11 12 func newInitCmd() *cobra.Command { 12 13 var repo string 13 14 var labels []string 15 + var issueTemplate string 14 16 15 17 cmd := &cobra.Command{ 16 18 Use: "init", ··· 28 30 29 31 cfg.GitHubRepo = repo 30 32 cfg.Labels = labels 33 + if strings.TrimSpace(issueTemplate) != "" { 34 + cfg.IssueTemplate = issueTemplate 35 + } 31 36 32 37 if err := config.Save(paths.ConfigFile, cfg); err != nil { 33 38 return err ··· 36 41 fmt.Fprintf(cmd.OutOrStdout(), "saved config: %s\n", paths.ConfigFile) 37 42 fmt.Fprintf(cmd.OutOrStdout(), "repo: %s\n", cfg.GitHubRepo) 38 43 fmt.Fprintf(cmd.OutOrStdout(), "labels: %v\n", cfg.Labels) 44 + fmt.Fprintf(cmd.OutOrStdout(), "issue_template: %s\n", cfg.IssueTemplate) 39 45 return nil 40 46 }, 41 47 } 42 48 43 49 cmd.Flags().StringVar(&repo, "repo", "", "GitHub repository for daily update issues (required)") 44 50 cmd.Flags().StringSliceVar(&labels, "labels", nil, "Labels to apply when creating issues (can be specified multiple times)") 51 + cmd.Flags().StringVar(&issueTemplate, "issue-template", config.Default().IssueTemplate, "Repository path to the GitHub issue template used by pad") 45 52 46 53 return cmd 47 54 }
+5 -1
cmd/list.go
··· 6 6 "text/tabwriter" 7 7 8 8 "github.com/spf13/cobra" 9 + "github.com/vieitesss/pad/internal/ghcli" 10 + "github.com/vieitesss/pad/internal/tui" 9 11 ) 10 12 11 13 func newListCmd() *cobra.Command { ··· 25 27 return err 26 28 } 27 29 28 - issues, err := env.gh.ListDailyUpdateIssues(ctx, env.cfg.GitHubRepo, env.cfg.Labels, limit) 30 + issues, err := tui.RunWithSpinner(ctx, "Fetching your daily updates", func(ctx context.Context) ([]ghcli.DailyUpdateIssue, error) { 31 + return env.gh.ListDailyUpdateIssues(ctx, env.cfg.GitHubRepo, env.cfg.Labels, limit) 32 + }) 29 33 if err != nil { 30 34 return err 31 35 }
+7 -2
cmd/repeat.go
··· 34 34 return err 35 35 } 36 36 37 + template, err := loadIssueTemplate(ctx, env) 38 + if err != nil { 39 + return err 40 + } 41 + 37 42 if !dryRun { 38 43 if err := ensureCanCreateForDate(ctx, env, resolvedDate); err != nil { 39 44 return err 40 45 } 41 46 } 42 47 43 - latestIssue, err := env.gh.LatestDailyUpdateIssue(ctx, env.cfg.GitHubRepo, env.cfg.Labels) 48 + latestIssue, err := fetchLatestDailyUpdate(ctx, env) 44 49 if err != nil { 45 50 if errors.Is(err, ghcli.ErrIssueNotFound) { 46 51 return fmt.Errorf("no previous daily update issues found for the authenticated user") ··· 49 54 return err 50 55 } 51 56 52 - entry := daily.EntryFromIssueBody(resolvedDate, latestIssue.Body) 57 + entry := daily.EntryFromIssueBody(resolvedDate, template, latestIssue.Body) 53 58 entry.Source = fmt.Sprintf("repeat:%s", latestIssue.Date) 54 59 55 60 if dryRun {
+4 -1
cmd/show.go
··· 7 7 8 8 "github.com/spf13/cobra" 9 9 "github.com/vieitesss/pad/internal/ghcli" 10 + "github.com/vieitesss/pad/internal/tui" 10 11 ) 11 12 12 13 func newShowCmd() *cobra.Command { ··· 31 32 return err 32 33 } 33 34 34 - issue, err := env.gh.FindDailyUpdateIssueByDate(ctx, env.cfg.GitHubRepo, env.cfg.Labels, resolvedDate) 35 + issue, err := tui.RunWithSpinner(ctx, fmt.Sprintf("Fetching daily update for %s", resolvedDate), func(ctx context.Context) (ghcli.DailyUpdateIssue, error) { 36 + return env.gh.FindDailyUpdateIssueByDate(ctx, env.cfg.GitHubRepo, env.cfg.Labels, resolvedDate) 37 + }) 35 38 if err != nil { 36 39 if errors.Is(err, ghcli.ErrIssueNotFound) { 37 40 return fmt.Errorf("no remote daily update issue found for %s", resolvedDate)
+33 -1
cmd/support.go
··· 10 10 "github.com/vieitesss/pad/internal/config" 11 11 "github.com/vieitesss/pad/internal/daily" 12 12 "github.com/vieitesss/pad/internal/ghcli" 13 + "github.com/vieitesss/pad/internal/issueform" 14 + "github.com/vieitesss/pad/internal/tui" 13 15 ) 14 16 15 17 type commandEnv struct { ··· 55 57 return err 56 58 } 57 59 58 - existingIssue, err := env.gh.FindDailyUpdateIssueByDate(ctx, env.cfg.GitHubRepo, env.cfg.Labels, date) 60 + existingIssue, err := tui.RunWithSpinner(ctx, "Checking for existing daily update", func(ctx context.Context) (ghcli.DailyUpdateIssue, error) { 61 + return env.gh.FindDailyUpdateIssueByDate(ctx, env.cfg.GitHubRepo, env.cfg.Labels, date) 62 + }) 59 63 if err == nil { 60 64 return fmt.Errorf("daily update issue already exists for %s: %s", date, existingIssue.URL) 61 65 } ··· 65 69 } 66 70 67 71 return fmt.Errorf("check existing GitHub issues: %w", err) 72 + } 73 + 74 + func loadIssueTemplate(ctx context.Context, env *commandEnv) (issueform.Template, error) { 75 + if err := env.gh.EnsureReady(ctx); err != nil { 76 + return issueform.Template{}, err 77 + } 78 + 79 + content, err := tui.RunWithSpinner(ctx, fmt.Sprintf("Loading issue template %s", env.cfg.IssueTemplate), func(ctx context.Context) ([]byte, error) { 80 + return env.gh.ReadRepositoryFile(ctx, env.cfg.GitHubRepo, env.cfg.IssueTemplate) 81 + }) 82 + if err != nil { 83 + return issueform.Template{}, fmt.Errorf("load issue template %q: %w", env.cfg.IssueTemplate, err) 84 + } 85 + 86 + template, err := tui.RunWithSpinner(ctx, "Parsing issue template", func(ctx context.Context) (issueform.Template, error) { 87 + return issueform.Parse(env.cfg.IssueTemplate, content) 88 + }) 89 + if err != nil { 90 + return issueform.Template{}, fmt.Errorf("parse issue template %q: %w", env.cfg.IssueTemplate, err) 91 + } 92 + 93 + return template, nil 94 + } 95 + 96 + func fetchLatestDailyUpdate(ctx context.Context, env *commandEnv) (ghcli.DailyUpdateIssue, error) { 97 + return tui.RunWithSpinner(ctx, "Fetching your latest daily update", func(ctx context.Context) (ghcli.DailyUpdateIssue, error) { 98 + return env.gh.LatestDailyUpdateIssue(ctx, env.cfg.GitHubRepo, env.cfg.Labels) 99 + }) 68 100 } 69 101 70 102 func createIssueFromEntry(ctx context.Context, env *commandEnv, entry daily.Entry) (daily.IssueRef, error) {
+1
go.mod
··· 8 8 github.com/charmbracelet/bubbletea v1.3.10 9 9 github.com/charmbracelet/lipgloss v1.1.0 10 10 github.com/spf13/cobra v1.10.2 11 + gopkg.in/yaml.v3 v3.0.1 11 12 ) 12 13 13 14 require (
+3
go.sum
··· 65 65 golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 66 66 golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= 67 67 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 68 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 68 69 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 70 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 71 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+6 -4
internal/config/config.go
··· 11 11 ) 12 12 13 13 type Config struct { 14 - GitHubRepo string `toml:"github_repo"` 15 - Labels []string `toml:"labels"` 14 + GitHubRepo string `toml:"github_repo"` 15 + Labels []string `toml:"labels"` 16 + IssueTemplate string `toml:"issue_template"` 16 17 } 17 18 18 19 func Default() Config { 19 20 return Config{ 20 - GitHubRepo: "", 21 - Labels: []string{}, 21 + GitHubRepo: "", 22 + Labels: []string{}, 23 + IssueTemplate: ".github/ISSUE_TEMPLATE/daily-update.yml", 22 24 } 23 25 } 24 26
+406 -139
internal/daily/entry.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "regexp" 5 6 "strings" 6 7 "time" 8 + 9 + "github.com/vieitesss/pad/internal/issueform" 7 10 ) 8 11 9 12 const DateLayout = "2006-01-02" 10 13 14 + // Legacy title formats (used as fallback when template has no title) 11 15 const issueTitlePrefix = "[Daily Update] [" 12 16 const reportTitlePrefix = "[Daily Report] " 17 + const carryoverFieldID = "pad_carryover" 13 18 14 19 type IssueRef struct { 15 20 Number int `json:"number"` 16 21 URL string `json:"url"` 17 22 } 18 23 24 + type Response struct { 25 + Text string `json:"text,omitempty"` 26 + Checked bool `json:"checked,omitempty"` 27 + } 28 + 19 29 type Entry struct { 20 - Date string `json:"date"` 21 - Yesterday string `json:"yesterday"` 22 - Today string `json:"today"` 23 - Blockers string `json:"blockers,omitempty"` 24 - ParkingLot bool `json:"parking_lot,omitempty"` 25 - ParkingLotDetails string `json:"parking_lot_details,omitempty"` 26 - AdditionalComments string `json:"additional_comments,omitempty"` 27 - Source string `json:"source,omitempty"` 28 - Issue *IssueRef `json:"issue,omitempty"` 29 - CreatedAt time.Time `json:"created_at"` 30 - UpdatedAt time.Time `json:"updated_at"` 30 + Date string `json:"date"` 31 + Template issueform.Template `json:"-"` 32 + Responses map[string]Response `json:"responses,omitempty"` 33 + Source string `json:"source,omitempty"` 34 + Issue *IssueRef `json:"issue,omitempty"` 35 + CreatedAt time.Time `json:"created_at"` 36 + UpdatedAt time.Time `json:"updated_at"` 37 + } 38 + 39 + type parsedSection struct { 40 + Heading string 41 + ID string 42 + Body string 43 + } 44 + 45 + var headingPattern = regexp.MustCompile(`^#{2,6}\s*(.*?)\s*(?:<!--\s*pad:id:([A-Za-z0-9._-]+)\s*-->)?\s*$`) 46 + 47 + var legacyFieldAliases = map[string][]string{ 48 + "yesterday": {"✅ What did you do yesterday?"}, 49 + "today": {"🎯 What will you do today?"}, 50 + "blockers": {"🚧 Any blockers?"}, 51 + "parking_lot": {"🚨 Do you request a Parking Lot or escalation?", "🚨 Parking Lot / Escalation"}, 52 + "parking_details": {"📝 Parking Lot Details", "🚨 Parking Lot / Escalation"}, 53 + "comments": {"💬 Additional Comments"}, 31 54 } 32 55 33 - func New(date string) Entry { 56 + func New(date string, template issueform.Template) Entry { 34 57 return Entry{ 35 - Date: date, 36 - Source: "manual", 58 + Date: date, 59 + Template: template, 60 + Responses: make(map[string]Response), 61 + Source: "manual", 37 62 } 38 63 } 39 64 40 - func EntryFromIssueBody(date, body string) Entry { 41 - sections := parseSections(body) 42 - parkingLot, parkingLotDetails := parseParkingLotSections(sections) 65 + func EntryFromIssueBody(date string, template issueform.Template, body string) Entry { 66 + entry := New(date, template) 67 + sections := parseSections(body, template) 68 + used := make(map[int]bool, len(sections)) 69 + 70 + for _, field := range template.EditableFields() { 71 + index, ok := matchSection(field, sections) 72 + if !ok { 73 + continue 74 + } 75 + 76 + used[index] = true 77 + applySection(&entry, field, sections[index]) 78 + } 43 79 44 - return Entry{ 45 - Date: date, 46 - Yesterday: normalizeParsedSection(sections["yesterday"]), 47 - Today: normalizeParsedSection(sections["today"]), 48 - Blockers: normalizeParsedSection(sections["blockers"]), 49 - ParkingLot: parkingLot, 50 - ParkingLotDetails: parkingLotDetails, 51 - AdditionalComments: normalizeParsedSection(sections["comments"]), 80 + if carryover := buildCarryoverBody(sections, used); carryover != "" { 81 + entry.Template = entry.Template.WithAppendedField(issueform.Field{ 82 + Type: issueform.FieldTextarea, 83 + ID: carryoverFieldID, 84 + Label: "🗂 Carryover From Previous Template", 85 + Description: "Responses from fields that no longer exist in the current template. Keep, move, or clear before creating.", 86 + }) 87 + entry.SetText(carryoverFieldID, carryover) 52 88 } 89 + 90 + return entry.Normalize() 53 91 } 54 92 93 + // Title generates the issue title from the template title, replacing date placeholders. 94 + // Falls back to the legacy format if no template title is available. 55 95 func (e Entry) Title() (string, error) { 56 - return TitleForDate(e.Date) 96 + templateTitle := strings.TrimSpace(e.Template.Title) 97 + if templateTitle == "" { 98 + // Fallback to legacy format 99 + return TitleForDate(e.Date) 100 + } 101 + 102 + // Parse the date 103 + t, err := time.Parse(DateLayout, e.Date) 104 + if err != nil { 105 + return "", fmt.Errorf("parse entry date: %w", err) 106 + } 107 + 108 + // Replace common date placeholders 109 + title := templateTitle 110 + replacements := map[string]string{ 111 + "YYYY/MM/DD": t.Format("2006/01/02"), 112 + "YYYY-MM-DD": t.Format("2006-01-02"), 113 + "DD/MM/YYYY": t.Format("02/01/2006"), 114 + "MM/DD/YYYY": t.Format("01/02/2006"), 115 + } 116 + 117 + for placeholder, value := range replacements { 118 + title = strings.ReplaceAll(title, placeholder, value) 119 + } 120 + 121 + return title, nil 57 122 } 58 123 124 + // TitleForDate generates the legacy title format (used as fallback). 59 125 func TitleForDate(date string) (string, error) { 60 126 return titleForDate(date, issueTitlePrefix, "2006/01/02", "]") 61 127 } ··· 64 130 return titleForDate(date, reportTitlePrefix, "2006/01/02", "") 65 131 } 66 132 133 + // DateFromIssueTitle extracts the date from an issue title. 134 + // It tries the template title format first, then falls back to legacy formats. 67 135 func DateFromIssueTitle(title string) (string, bool) { 136 + // Try to extract date using common patterns 137 + date, ok := extractDateFromTemplateTitle(title) 138 + if ok { 139 + return date, true 140 + } 141 + 142 + // Fall back to legacy format 68 143 return dateFromTitle(title, issueTitlePrefix, "]") 69 144 } 70 145 146 + // extractDateFromTemplateTitle tries to extract a date from a template-style title. 147 + func extractDateFromTemplateTitle(title string) (string, bool) { 148 + // Common date patterns in titles 149 + patterns := []string{ 150 + "2006/01/02", // YYYY/MM/DD 151 + "2006-01-02", // YYYY-MM-DD 152 + "01/02/2006", // MM/DD/YYYY 153 + "02/01/2006", // DD/MM/YYYY 154 + } 155 + 156 + for _, pattern := range patterns { 157 + // Try to find a substring matching this pattern 158 + if idx := findDatePattern(title, pattern); idx != -1 { 159 + dateStr := title[idx : idx+len(pattern)] 160 + t, err := time.Parse(pattern, dateStr) 161 + if err == nil { 162 + return t.Format(DateLayout), true 163 + } 164 + } 165 + } 166 + 167 + return "", false 168 + } 169 + 170 + // findDatePattern finds the start index of a date pattern in a string. 171 + func findDatePattern(s, pattern string) int { 172 + // Simple heuristic: look for a substring that matches the pattern structure 173 + // Count the number of digits and separators 174 + digitCount := 0 175 + sepCount := 0 176 + for _, r := range pattern { 177 + if r >= '0' && r <= '9' { 178 + digitCount++ 179 + } else { 180 + sepCount++ 181 + } 182 + } 183 + 184 + // Look for potential matches 185 + for i := 0; i <= len(s)-len(pattern); i++ { 186 + substr := s[i : i+len(pattern)] 187 + if matchesPattern(substr, pattern) { 188 + return i 189 + } 190 + } 191 + 192 + return -1 193 + } 194 + 195 + // matchesPattern checks if a string matches a date pattern structure. 196 + func matchesPattern(s, pattern string) bool { 197 + if len(s) != len(pattern) { 198 + return false 199 + } 200 + 201 + for i := 0; i < len(pattern); i++ { 202 + patternChar := pattern[i] 203 + strChar := s[i] 204 + 205 + if patternChar >= '0' && patternChar <= '9' { 206 + // Pattern expects a digit 207 + if strChar < '0' || strChar > '9' { 208 + return false 209 + } 210 + } else { 211 + // Pattern expects a specific separator 212 + if strChar != patternChar { 213 + return false 214 + } 215 + } 216 + } 217 + 218 + return true 219 + } 220 + 71 221 func DateFromReportTitle(title string) (string, bool) { 72 222 return dateFromTitle(title, reportTitlePrefix, "") 73 223 } 74 224 225 + func (e Entry) Body() string { 226 + blocks := make([]string, 0, len(e.Template.Fields)) 227 + for _, field := range e.Template.Fields { 228 + switch field.Type { 229 + case issueform.FieldMarkdown: 230 + if strings.TrimSpace(field.Markdown) == "" { 231 + continue 232 + } 233 + blocks = append(blocks, field.Markdown) 234 + case issueform.FieldTextarea, issueform.FieldInput: 235 + blocks = append(blocks, renderSection(field, normalizeSectionBody(e.Text(field.ID)))) 236 + case issueform.FieldCheckboxes: 237 + blocks = append(blocks, renderSection(field, renderCheckboxBody(field, e.Checked(field.ID)))) 238 + } 239 + } 240 + 241 + return strings.Join(blocks, "\n\n") 242 + } 243 + 244 + func (e Entry) ValidateForCreate() error { 245 + for _, field := range e.Template.EditableFields() { 246 + if !field.Required { 247 + continue 248 + } 249 + 250 + switch field.Type { 251 + case issueform.FieldCheckboxes: 252 + if e.Checked(field.ID) { 253 + continue 254 + } 255 + default: 256 + if strings.TrimSpace(e.Text(field.ID)) != "" { 257 + continue 258 + } 259 + } 260 + 261 + return fmt.Errorf("section %q is required; fill it in with `pad create` or `pad repeat`", field.Label) 262 + } 263 + 264 + return nil 265 + } 266 + 267 + func (e Entry) Normalize() Entry { 268 + if e.Responses == nil { 269 + e.Responses = make(map[string]Response) 270 + } 271 + 272 + for key, response := range e.Responses { 273 + response.Text = strings.TrimSpace(response.Text) 274 + e.Responses[key] = response 275 + } 276 + 277 + e.Source = strings.TrimSpace(e.Source) 278 + return e 279 + } 280 + 281 + func (e Entry) Text(fieldID string) string { 282 + return e.Responses[fieldID].Text 283 + } 284 + 285 + func (e *Entry) SetText(fieldID, value string) { 286 + if e.Responses == nil { 287 + e.Responses = make(map[string]Response) 288 + } 289 + 290 + response := e.Responses[fieldID] 291 + response.Text = value 292 + e.Responses[fieldID] = response 293 + } 294 + 295 + func (e Entry) Checked(fieldID string) bool { 296 + return e.Responses[fieldID].Checked 297 + } 298 + 299 + func (e *Entry) SetChecked(fieldID string, checked bool) { 300 + if e.Responses == nil { 301 + e.Responses = make(map[string]Response) 302 + } 303 + 304 + response := e.Responses[fieldID] 305 + response.Checked = checked 306 + e.Responses[fieldID] = response 307 + } 308 + 75 309 func titleForDate(date, prefix, format, suffix string) (string, error) { 76 310 t, err := time.Parse(DateLayout, date) 77 311 if err != nil { ··· 95 329 return t.Format(DateLayout), true 96 330 } 97 331 98 - func (e Entry) Body() string { 99 - sections := []section{ 100 - {Title: "✅ What did you do yesterday?", Body: e.Yesterday}, 101 - {Title: "🎯 What will you do today?", Body: e.Today}, 102 - {Title: "🚧 Any blockers?", Body: e.Blockers}, 332 + func renderSection(field issueform.Field, body string) string { 333 + heading := "## " + field.Label 334 + if field.ID != "" { 335 + heading += " <!-- pad:id:" + field.ID + " -->" 103 336 } 337 + return heading + "\n" + body 338 + } 104 339 105 - parkingLotBody := buildParkingLotBody(e) 106 - if parkingLotBody != "" { 107 - sections = append(sections, section{Title: "🚨 Parking Lot / Escalation", Body: parkingLotBody}) 340 + func renderCheckboxBody(field issueform.Field, checked bool) string { 341 + lines := make([]string, 0, len(field.Options)) 342 + for index, option := range field.Options { 343 + marker := " " 344 + if checked && index == 0 { 345 + marker = "x" 346 + } 347 + lines = append(lines, fmt.Sprintf("- [%s] %s", marker, option.Label)) 108 348 } 349 + return strings.Join(lines, "\n") 350 + } 109 351 110 - if strings.TrimSpace(e.AdditionalComments) != "" { 111 - sections = append(sections, section{Title: "💬 Additional Comments", Body: e.AdditionalComments}) 112 - } 352 + func parseSections(body string, template issueform.Template) []parsedSection { 353 + sections := make([]parsedSection, 0) 354 + current := parsedSection{} 355 + active := false 356 + allowedHeadings := collectAllowedHeadings(template) 113 357 114 - var body strings.Builder 115 - for index, section := range sections { 116 - if index > 0 { 117 - body.WriteString("\n\n") 358 + for _, line := range strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") { 359 + heading, id, ok := parseHeading(line) 360 + if ok && shouldStartSection(heading, id, allowedHeadings) { 361 + if active { 362 + current.Body = strings.TrimSpace(current.Body) 363 + sections = append(sections, current) 364 + } 365 + current = parsedSection{Heading: heading, ID: id} 366 + active = true 367 + continue 118 368 } 119 369 120 - body.WriteString("## ") 121 - body.WriteString(section.Title) 122 - body.WriteString("\n") 123 - body.WriteString(normalizeSectionBody(section.Body)) 124 - } 125 - 126 - return body.String() 127 - } 370 + if !active { 371 + continue 372 + } 128 373 129 - func (e Entry) ValidateForCreate() error { 130 - if strings.TrimSpace(e.Yesterday) == "" { 131 - return fmt.Errorf("yesterday section is required; fill it in with `pad create` or `pad repeat`") 374 + if current.Body == "" { 375 + current.Body = line 376 + continue 377 + } 378 + current.Body += "\n" + line 132 379 } 133 380 134 - if strings.TrimSpace(e.Today) == "" { 135 - return fmt.Errorf("today section is required; fill it in with `pad create` or `pad repeat`") 381 + if active { 382 + current.Body = strings.TrimSpace(current.Body) 383 + sections = append(sections, current) 136 384 } 137 385 138 - return nil 386 + return sections 139 387 } 140 388 141 - func (e Entry) Normalize() Entry { 142 - e.Yesterday = strings.TrimSpace(e.Yesterday) 143 - e.Today = strings.TrimSpace(e.Today) 144 - e.Blockers = strings.TrimSpace(e.Blockers) 145 - e.ParkingLotDetails = strings.TrimSpace(e.ParkingLotDetails) 146 - e.AdditionalComments = strings.TrimSpace(e.AdditionalComments) 147 - e.Source = strings.TrimSpace(e.Source) 148 - return e 389 + func collectAllowedHeadings(template issueform.Template) map[string]struct{} { 390 + allowed := make(map[string]struct{}) 391 + for _, field := range template.EditableFields() { 392 + allowed[normalizeHeading(field.Label)] = struct{}{} 393 + } 394 + for _, aliases := range legacyFieldAliases { 395 + for _, alias := range aliases { 396 + allowed[normalizeHeading(alias)] = struct{}{} 397 + } 398 + } 399 + return allowed 149 400 } 150 401 151 - type section struct { 152 - Title string 153 - Body string 402 + func shouldStartSection(heading, id string, allowed map[string]struct{}) bool { 403 + if id != "" { 404 + return true 405 + } 406 + _, ok := allowed[normalizeHeading(heading)] 407 + return ok 154 408 } 155 409 156 - func normalizeSectionBody(body string) string { 157 - trimmed := strings.TrimSpace(body) 410 + func parseHeading(line string) (string, string, bool) { 411 + trimmed := strings.TrimSpace(line) 158 412 if trimmed == "" { 159 - return "_None._" 413 + return "", "", false 160 414 } 161 415 162 - return trimmed 416 + match := headingPattern.FindStringSubmatch(trimmed) 417 + if len(match) == 0 { 418 + return "", "", false 419 + } 420 + 421 + return strings.TrimSpace(match[1]), strings.TrimSpace(match[2]), true 163 422 } 164 423 165 - func buildParkingLotBody(e Entry) string { 166 - var parts []string 167 - 168 - if e.ParkingLot { 169 - parts = append(parts, "- ✅ Yes, I need a Parking Lot or escalation") 424 + func matchSection(field issueform.Field, sections []parsedSection) (int, bool) { 425 + if field.ID != "" { 426 + for index, section := range sections { 427 + if section.ID == field.ID { 428 + return index, true 429 + } 430 + } 170 431 } 171 432 172 - if details := strings.TrimSpace(e.ParkingLotDetails); details != "" { 173 - parts = append(parts, details) 433 + wanted := append([]string{normalizeHeading(field.Label)}, normalizedAliases(field.ID)...) 434 + for index, section := range sections { 435 + heading := normalizeHeading(section.Heading) 436 + for _, alias := range wanted { 437 + if alias == heading { 438 + return index, true 439 + } 440 + } 174 441 } 175 442 176 - return strings.Join(parts, "\n\n") 443 + return 0, false 177 444 } 178 445 179 - func parseSections(body string) map[string]string { 180 - sections := make(map[string][]string) 181 - current := "" 446 + func normalizedAliases(fieldID string) []string { 447 + aliases := legacyFieldAliases[fieldID] 448 + normalized := make([]string, 0, len(aliases)) 449 + for _, alias := range aliases { 450 + normalized = append(normalized, normalizeHeading(alias)) 451 + } 452 + return normalized 453 + } 182 454 183 - for _, line := range strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") { 184 - if key, ok := sectionKey(strings.TrimSpace(line)); ok { 185 - current = key 186 - continue 187 - } 455 + func normalizeHeading(heading string) string { 456 + return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(heading)), " ")) 457 + } 188 458 189 - if current == "" { 190 - continue 459 + func applySection(entry *Entry, field issueform.Field, section parsedSection) { 460 + switch field.Type { 461 + case issueform.FieldCheckboxes: 462 + entry.SetChecked(field.ID, parseCheckboxValue(field, section)) 463 + case issueform.FieldTextarea, issueform.FieldInput: 464 + if normalizeHeading(section.Heading) == normalizeHeading("🚨 Parking Lot / Escalation") && field.ID == "parking_details" { 465 + _, details := parseCombinedParkingLot(section.Body) 466 + entry.SetText(field.ID, details) 467 + return 191 468 } 192 - 193 - sections[current] = append(sections[current], line) 469 + entry.SetText(field.ID, normalizeParsedSection(section.Body)) 194 470 } 471 + } 195 472 196 - parsed := make(map[string]string, len(sections)) 197 - for key, lines := range sections { 198 - parsed[key] = strings.TrimSpace(strings.Join(lines, "\n")) 473 + func parseCheckboxValue(field issueform.Field, section parsedSection) bool { 474 + if normalizeHeading(section.Heading) == normalizeHeading("🚨 Parking Lot / Escalation") { 475 + checked, _ := parseCombinedParkingLot(section.Body) 476 + return checked 199 477 } 200 478 201 - return parsed 202 - } 203 - 204 - func sectionKey(line string) (string, bool) { 205 - if !strings.HasPrefix(line, "##") { 206 - return "", false 479 + body := strings.ToLower(strings.TrimSpace(section.Body)) 480 + if strings.Contains(body, "[x]") { 481 + return true 207 482 } 208 483 209 - heading := strings.TrimSpace(strings.TrimLeft(line, "# ")) 210 - switch heading { 211 - case "✅ What did you do yesterday?": 212 - return "yesterday", true 213 - case "🎯 What will you do today?": 214 - return "today", true 215 - case "🚧 Any blockers?": 216 - return "blockers", true 217 - case "🚨 Do you request a Parking Lot or escalation?": 218 - return "parking_checkbox", true 219 - case "🚨 Parking Lot / Escalation": 220 - return "parking_combined", true 221 - case "📝 Parking Lot Details": 222 - return "parking_details", true 223 - case "💬 Additional Comments": 224 - return "comments", true 225 - default: 226 - return "", false 484 + for _, option := range field.Options { 485 + if strings.Contains(body, strings.ToLower(option.Label)) && !strings.Contains(body, "[ ]") { 486 + return true 487 + } 227 488 } 489 + 490 + return false 228 491 } 229 492 230 - func parseParkingLotSections(sections map[string]string) (bool, string) { 231 - parkingLot := false 232 - details := normalizeParsedSection(sections["parking_details"]) 233 - 234 - checkbox := normalizeParsedSection(sections["parking_checkbox"]) 235 - if strings.Contains(strings.ToLower(checkbox), "[x]") { 236 - parkingLot = true 237 - } 493 + func buildCarryoverBody(sections []parsedSection, used map[int]bool) string { 494 + blocks := make([]string, 0) 495 + for index, section := range sections { 496 + if used[index] { 497 + continue 498 + } 238 499 239 - combined := normalizeParsedSection(sections["parking_combined"]) 240 - if combined != "" { 241 - combinedParkingLot, combinedDetails := parseCombinedParkingLot(combined) 242 - if combinedParkingLot { 243 - parkingLot = true 500 + body := normalizeParsedSection(section.Body) 501 + if body == "" { 502 + continue 244 503 } 245 - if details == "" { 246 - details = combinedDetails 504 + 505 + heading := section.Heading 506 + if heading == "" { 507 + heading = "Previous Response" 247 508 } 509 + blocks = append(blocks, fmt.Sprintf("### %s\n%s", heading, body)) 248 510 } 249 511 250 - if details != "" { 251 - parkingLot = true 512 + return strings.Join(blocks, "\n\n") 513 + } 514 + 515 + func normalizeSectionBody(body string) string { 516 + trimmed := strings.TrimSpace(body) 517 + if trimmed == "" { 518 + return "_None._" 252 519 } 253 520 254 - return parkingLot, details 521 + return trimmed 255 522 } 256 523 257 524 func parseCombinedParkingLot(body string) (bool, string) { ··· 267 534 continue 268 535 } 269 536 270 - if strings.Contains(trimmed, "Yes, I need a Parking Lot or escalation") { 537 + if strings.Contains(trimmed, "Yes, I need a Parking Lot or escalation") || strings.Contains(strings.ToLower(trimmed), "[x]") { 271 538 parkingLot = true 272 539 continue 273 540 } ··· 275 542 detailLines = append(detailLines, line) 276 543 } 277 544 278 - details := normalizeParsedSection(strings.TrimSpace(strings.Join(detailLines, "\n"))) 545 + details := normalizeParsedSection(strings.Join(detailLines, "\n")) 279 546 if details != "" { 280 547 parkingLot = true 281 548 } ··· 285 552 286 553 func normalizeParsedSection(body string) string { 287 554 trimmed := strings.TrimSpace(body) 288 - switch trimmed { 289 - case "", "_No response_", "_None._": 555 + switch strings.ToLower(trimmed) { 556 + case "", "_no response_", "_none._", "none": 290 557 return "" 291 558 default: 292 559 return trimmed
+290 -35
internal/daily/entry_test.go
··· 1 1 package daily 2 2 3 3 import ( 4 + "fmt" 4 5 "strings" 5 6 "testing" 7 + 8 + "github.com/vieitesss/pad/internal/issueform" 6 9 ) 7 10 8 11 func TestEntryFromIssueBodyParsesIssueFormMarkdown(t *testing.T) { 12 + template := mustTemplate(t) 9 13 body := `### ✅ What did you do yesterday? 10 14 11 15 - https://github.com/prefapp/features/issues/918 ··· 30 34 31 35 - Offline after 17:00` 32 36 33 - got := EntryFromIssueBody("2026-04-17", body) 37 + got := EntryFromIssueBody("2026-04-17", template, body) 34 38 35 39 if got.Date != "2026-04-17" { 36 40 t.Fatalf("expected date 2026-04-17, got %q", got.Date) 37 41 } 38 42 39 - if got.Yesterday != "- https://github.com/prefapp/features/issues/918" { 40 - t.Fatalf("unexpected yesterday %q", got.Yesterday) 43 + if got.Text("yesterday") != "- https://github.com/prefapp/features/issues/918" { 44 + t.Fatalf("unexpected yesterday %q", got.Text("yesterday")) 41 45 } 42 46 43 - if got.Today != "- https://github.com/prefapp/gitops-k8s/pull/1755" { 44 - t.Fatalf("unexpected today %q", got.Today) 47 + if got.Text("today") != "- https://github.com/prefapp/gitops-k8s/pull/1755" { 48 + t.Fatalf("unexpected today %q", got.Text("today")) 45 49 } 46 50 47 - if got.Blockers != "" { 48 - t.Fatalf("expected empty blockers, got %q", got.Blockers) 51 + if got.Text("blockers") != "" { 52 + t.Fatalf("expected empty blockers, got %q", got.Text("blockers")) 49 53 } 50 54 51 - if !got.ParkingLot { 55 + if !got.Checked("parking_lot") { 52 56 t.Fatalf("expected parking lot to be true") 53 57 } 54 58 55 - if got.ParkingLotDetails != "- Need help with deployment scope" { 56 - t.Fatalf("unexpected parking lot details %q", got.ParkingLotDetails) 59 + if got.Text("parking_details") != "- Need help with deployment scope" { 60 + t.Fatalf("unexpected parking lot details %q", got.Text("parking_details")) 57 61 } 58 62 59 - if got.AdditionalComments != "- Offline after 17:00" { 60 - t.Fatalf("unexpected additional comments %q", got.AdditionalComments) 63 + if got.Text("comments") != "- Offline after 17:00" { 64 + t.Fatalf("unexpected additional comments %q", got.Text("comments")) 61 65 } 62 66 } 63 67 64 - func TestEntryFromIssueBodyParsesPadMarkdown(t *testing.T) { 68 + func TestEntryFromIssueBodyParsesLegacyPadMarkdown(t *testing.T) { 69 + template := mustTemplate(t) 65 70 body := `## ✅ What did you do yesterday? 66 71 - Reviewed PR #42 67 72 ··· 79 84 ## 💬 Additional Comments 80 85 - Waiting for design confirmation` 81 86 82 - got := EntryFromIssueBody("2026-04-17", body) 87 + got := EntryFromIssueBody("2026-04-17", template, body) 83 88 84 - if got.Yesterday != "- Reviewed PR #42" { 85 - t.Fatalf("unexpected yesterday %q", got.Yesterday) 89 + if got.Text("yesterday") != "- Reviewed PR #42" { 90 + t.Fatalf("unexpected yesterday %q", got.Text("yesterday")) 86 91 } 87 92 88 - if got.Today != "- Continue feature work" { 89 - t.Fatalf("unexpected today %q", got.Today) 93 + if got.Text("today") != "- Continue feature work" { 94 + t.Fatalf("unexpected today %q", got.Text("today")) 90 95 } 91 96 92 - if got.Blockers != "" { 93 - t.Fatalf("expected empty blockers, got %q", got.Blockers) 97 + if got.Text("blockers") != "" { 98 + t.Fatalf("expected empty blockers, got %q", got.Text("blockers")) 94 99 } 95 100 96 - if !got.ParkingLot { 101 + if !got.Checked("parking_lot") { 97 102 t.Fatalf("expected parking lot to be true") 98 103 } 99 104 100 - if got.ParkingLotDetails != "- Need product input" { 101 - t.Fatalf("unexpected parking lot details %q", got.ParkingLotDetails) 105 + if got.Text("parking_details") != "- Need product input" { 106 + t.Fatalf("unexpected parking lot details %q", got.Text("parking_details")) 107 + } 108 + 109 + if got.Text("comments") != "- Waiting for design confirmation" { 110 + t.Fatalf("unexpected additional comments %q", got.Text("comments")) 111 + } 112 + } 113 + 114 + func TestEntryFromIssueBodyUsesHiddenIDsWhenLabelsChange(t *testing.T) { 115 + template := mustTemplateWithRenamedYesterday(t) 116 + body := `## Yesterday Work <!-- pad:id:yesterday --> 117 + - Reviewed PR #42 118 + 119 + ## Current Focus <!-- pad:id:today --> 120 + - Continue feature work` 121 + 122 + got := EntryFromIssueBody("2026-04-17", template, body) 123 + 124 + if got.Text("yesterday") != "- Reviewed PR #42" { 125 + t.Fatalf("unexpected yesterday %q", got.Text("yesterday")) 126 + } 127 + 128 + if got.Text("today") != "- Continue feature work" { 129 + t.Fatalf("unexpected today %q", got.Text("today")) 130 + } 131 + } 132 + 133 + func TestEntryFromIssueBodyAddsCarryoverForRemovedFields(t *testing.T) { 134 + template := mustTemplateWithRenamedYesterday(t) 135 + body := `## Yesterday Work <!-- pad:id:yesterday --> 136 + - Reviewed PR #42 137 + 138 + ## Current Focus <!-- pad:id:today --> 139 + - Continue feature work 140 + 141 + ## 💬 Additional Comments <!-- pad:id:comments --> 142 + - Offline after 17:00` 143 + 144 + got := EntryFromIssueBody("2026-04-17", template, body) 145 + 146 + if got.Text(carryoverFieldID) == "" { 147 + t.Fatalf("expected carryover field to be populated") 102 148 } 103 149 104 - if got.AdditionalComments != "- Waiting for design confirmation" { 105 - t.Fatalf("unexpected additional comments %q", got.AdditionalComments) 150 + if !strings.Contains(got.Text(carryoverFieldID), "Additional Comments") { 151 + t.Fatalf("expected carryover to mention removed section, got %q", got.Text(carryoverFieldID)) 152 + } 153 + if !strings.Contains(got.Body(), "Carryover From Previous Template") { 154 + t.Fatalf("expected rendered body to include carryover section") 106 155 } 107 156 } 108 157 109 - func TestBodyRendersTemplateSections(t *testing.T) { 110 - entry := Entry{ 111 - Date: "2026-04-16", 112 - Yesterday: "- Reviewed PR #42", 113 - Today: "- Continue feature work", 158 + func TestEntryFromIssueBodyKeepsMarkdownHeadingsInsideResponseBody(t *testing.T) { 159 + template := mustTemplate(t) 160 + body := `## ✅ What did you do yesterday? <!-- pad:id:yesterday --> 161 + - Reviewed PR #42 162 + 163 + ## 🎯 What will you do today? <!-- pad:id:today --> 164 + - Continue feature work 165 + 166 + ### Notes 167 + - Include nested heading in response 168 + 169 + ## 🚧 Any blockers? <!-- pad:id:blockers --> 170 + _None._` 171 + 172 + got := EntryFromIssueBody("2026-04-17", template, body) 173 + 174 + if !strings.Contains(got.Text("today"), "### Notes") { 175 + t.Fatalf("expected nested heading to stay inside today response, got %q", got.Text("today")) 114 176 } 177 + } 178 + 179 + func TestBodyRendersTemplateSectionsAndHiddenIDs(t *testing.T) { 180 + entry := New("2026-04-16", mustTemplate(t)) 181 + entry.SetText("yesterday", "- Reviewed PR #42") 182 + entry.SetText("today", "- Continue feature work") 115 183 116 184 body := entry.Body() 117 185 118 186 checks := []string{ 119 - "## ✅ What did you do yesterday?", 120 - "## 🎯 What will you do today?", 121 - "## 🚧 Any blockers?", 187 + "## Daily Standup Update", 188 + "## ✅ What did you do yesterday? <!-- pad:id:yesterday -->", 189 + "## 🎯 What will you do today? <!-- pad:id:today -->", 190 + "## 🚧 Any blockers? <!-- pad:id:blockers -->", 122 191 "_None._", 123 192 } 124 193 ··· 127 196 t.Fatalf("expected body to contain %q, got %q", check, body) 128 197 } 129 198 } 199 + } 130 200 131 - if strings.Contains(body, "Parking Lot / Escalation") { 132 - t.Fatalf("expected empty parking lot section to be omitted") 201 + func TestValidateForCreateRequiresTemplateRequiredFields(t *testing.T) { 202 + entry := New("2026-04-16", mustTemplate(t)) 203 + entry.SetText("today", "- Continue feature work") 204 + 205 + if err := entry.ValidateForCreate(); err == nil { 206 + t.Fatalf("expected validation error") 133 207 } 134 208 } 135 209 ··· 160 234 t.Fatalf("expected parsed date 2026-04-16, got %q", date) 161 235 } 162 236 } 237 + 238 + func TestEntryTitleUsesTemplateTitle(t *testing.T) { 239 + template := mustTemplateWithTitle(t, "[Standup] YYYY/MM/DD") 240 + entry := New("2026-04-16", template) 241 + 242 + title, err := entry.Title() 243 + if err != nil { 244 + t.Fatalf("unexpected error: %v", err) 245 + } 246 + 247 + if title != "[Standup] 2026/04/16" { 248 + t.Fatalf("expected title '[Standup] 2026/04/16', got %q", title) 249 + } 250 + } 251 + 252 + func TestEntryTitleSupportsMultipleDateFormats(t *testing.T) { 253 + tests := []struct { 254 + templateTitle string 255 + expected string 256 + }{ 257 + {"Daily Update - YYYY-MM-DD", "Daily Update - 2026-04-16"}, 258 + {"Standup MM/DD/YYYY", "Standup 04/16/2026"}, 259 + {"Update DD/MM/YYYY", "Update 16/04/2026"}, 260 + } 261 + 262 + for _, test := range tests { 263 + template := mustTemplateWithTitle(t, test.templateTitle) 264 + entry := New("2026-04-16", template) 265 + 266 + title, err := entry.Title() 267 + if err != nil { 268 + t.Fatalf("unexpected error: %v", err) 269 + } 270 + 271 + if title != test.expected { 272 + t.Fatalf("expected title %q, got %q", test.expected, title) 273 + } 274 + } 275 + } 276 + 277 + func TestEntryTitleFallsBackToLegacyFormat(t *testing.T) { 278 + template := mustTemplate(t) // No title set 279 + entry := New("2026-04-16", template) 280 + 281 + title, err := entry.Title() 282 + if err != nil { 283 + t.Fatalf("unexpected error: %v", err) 284 + } 285 + 286 + // Should fallback to legacy format since template has no title 287 + if title != "[Daily Update] [2026/04/16]" { 288 + t.Fatalf("expected legacy title format, got %q", title) 289 + } 290 + } 291 + 292 + func TestDateFromIssueTitleWithTemplateTitle(t *testing.T) { 293 + date, ok := DateFromIssueTitle("[Standup] 2026/04/16") 294 + if !ok { 295 + t.Fatalf("expected template-style title to parse") 296 + } 297 + 298 + if date != "2026-04-16" { 299 + t.Fatalf("expected parsed date 2026-04-16, got %q", date) 300 + } 301 + } 302 + 303 + func TestDateFromIssueTitleWithDifferentFormats(t *testing.T) { 304 + tests := []struct { 305 + title string 306 + expected string 307 + }{ 308 + {"Daily Update - 2026-04-16", "2026-04-16"}, 309 + {"Standup 04/16/2026", "2026-04-16"}, 310 + {"Update 16/04/2026", "2026-04-16"}, 311 + } 312 + 313 + for _, test := range tests { 314 + date, ok := DateFromIssueTitle(test.title) 315 + if !ok { 316 + t.Fatalf("expected title %q to parse", test.title) 317 + } 318 + 319 + if date != test.expected { 320 + t.Fatalf("expected parsed date %q, got %q", test.expected, date) 321 + } 322 + } 323 + } 324 + 325 + func mustTemplate(t *testing.T) issueform.Template { 326 + t.Helper() 327 + tmpl, err := issueform.Parse(".github/ISSUE_TEMPLATE/daily-update.yml", []byte(` 328 + body: 329 + - type: markdown 330 + attributes: 331 + value: | 332 + ## Daily Standup Update 333 + 334 + - type: textarea 335 + id: yesterday 336 + attributes: 337 + label: "✅ What did you do yesterday?" 338 + validations: 339 + required: true 340 + 341 + - type: textarea 342 + id: today 343 + attributes: 344 + label: "🎯 What will you do today?" 345 + validations: 346 + required: true 347 + 348 + - type: textarea 349 + id: blockers 350 + attributes: 351 + label: "🚧 Any blockers?" 352 + 353 + - type: checkboxes 354 + id: parking_lot 355 + attributes: 356 + label: "🚨 Do you request a Parking Lot or escalation?" 357 + options: 358 + - label: "✅ Yes, I need a Parking Lot or escalation" 359 + 360 + - type: textarea 361 + id: parking_details 362 + attributes: 363 + label: "📝 Parking Lot Details" 364 + 365 + - type: textarea 366 + id: comments 367 + attributes: 368 + label: "💬 Additional Comments" 369 + `)) 370 + if err != nil { 371 + t.Fatalf("parse template: %v", err) 372 + } 373 + return tmpl 374 + } 375 + 376 + func mustTemplateWithRenamedYesterday(t *testing.T) issueform.Template { 377 + t.Helper() 378 + tmpl, err := issueform.Parse(".github/ISSUE_TEMPLATE/daily-update.yml", []byte(` 379 + body: 380 + - type: textarea 381 + id: yesterday 382 + attributes: 383 + label: Yesterday Work 384 + validations: 385 + required: true 386 + 387 + - type: textarea 388 + id: today 389 + attributes: 390 + label: Current Focus 391 + validations: 392 + required: true 393 + `)) 394 + if err != nil { 395 + t.Fatalf("parse template: %v", err) 396 + } 397 + return tmpl 398 + } 399 + 400 + func mustTemplateWithTitle(t *testing.T, title string) issueform.Template { 401 + t.Helper() 402 + yaml := fmt.Sprintf(` 403 + title: "%s" 404 + body: 405 + - type: textarea 406 + id: yesterday 407 + attributes: 408 + label: "What did you do yesterday?" 409 + validations: 410 + required: true 411 + `, title) 412 + tmpl, err := issueform.Parse(".github/ISSUE_TEMPLATE/daily-update.yml", []byte(yaml)) 413 + if err != nil { 414 + t.Fatalf("parse template: %v", err) 415 + } 416 + return tmpl 417 + }
+55
internal/ghcli/issues.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/base64" 5 6 "encoding/json" 6 7 "errors" 7 8 "fmt" ··· 55 56 UpdatedAt string `json:"updatedAt"` 56 57 } 57 58 59 + type repoContentItem struct { 60 + Content string `json:"content"` 61 + Encoding string `json:"encoding"` 62 + } 63 + 58 64 func New() *Client { 59 65 return &Client{run: runGH} 60 66 } ··· 99 105 } 100 106 101 107 return daily.IssueRef{Number: issueNumber, URL: issueURL}, nil 108 + } 109 + 110 + func (c *Client) ReadRepositoryFile(ctx context.Context, repo, filePath string) ([]byte, error) { 111 + owner, name, err := splitRepo(repo) 112 + if err != nil { 113 + return nil, err 114 + } 115 + 116 + apiPath := fmt.Sprintf("repos/%s/%s/contents/%s", url.PathEscape(owner), url.PathEscape(name), escapeRepoPath(filePath)) 117 + output, err := c.run(ctx, "api", apiPath) 118 + if err != nil { 119 + return nil, fmt.Errorf("read repository file: %s", strings.TrimSpace(string(output))) 120 + } 121 + 122 + var item repoContentItem 123 + if err := json.Unmarshal(output, &item); err != nil { 124 + return nil, fmt.Errorf("decode repository file: %w", err) 125 + } 126 + 127 + if item.Encoding != "base64" { 128 + return nil, fmt.Errorf("unsupported repository file encoding %q", item.Encoding) 129 + } 130 + 131 + content, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(item.Content, "\n", "")) 132 + if err != nil { 133 + return nil, fmt.Errorf("decode repository file content: %w", err) 134 + } 135 + 136 + return content, nil 102 137 } 103 138 104 139 func (c *Client) ListDailyUpdateIssues(ctx context.Context, repo string, labels []string, limit int) ([]DailyUpdateIssue, error) { ··· 299 334 } 300 335 301 336 return number, nil 337 + } 338 + 339 + func splitRepo(repo string) (string, string, error) { 340 + parts := strings.SplitN(strings.TrimSpace(repo), "/", 2) 341 + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { 342 + return "", "", fmt.Errorf("invalid repository %q", repo) 343 + } 344 + return parts[0], parts[1], nil 345 + } 346 + 347 + func escapeRepoPath(filePath string) string { 348 + parts := strings.Split(strings.Trim(filePath, "/"), "/") 349 + escaped := make([]string, 0, len(parts)) 350 + for _, part := range parts { 351 + if part == "" { 352 + continue 353 + } 354 + escaped = append(escaped, url.PathEscape(part)) 355 + } 356 + return strings.Join(escaped, "/") 302 357 } 303 358 304 359 func runGH(ctx context.Context, args ...string) ([]byte, error) {
+22
internal/ghcli/issues_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/base64" 5 6 "strings" 6 7 "testing" 7 8 ) ··· 125 126 t.Fatalf("expected team report body, got %q", issue.Body) 126 127 } 127 128 } 129 + 130 + func TestReadRepositoryFileDecodesBase64Content(t *testing.T) { 131 + client := newForTests(func(_ context.Context, args ...string) ([]byte, error) { 132 + joined := strings.Join(args, " ") 133 + if !strings.Contains(joined, "api repos/prefapp/doc-daily-updates/contents/.github/ISSUE_TEMPLATE/daily-update.yml") { 134 + t.Fatalf("unexpected gh command %q", joined) 135 + } 136 + 137 + encoded := base64.StdEncoding.EncodeToString([]byte("title: daily update\n")) 138 + return []byte(`{"encoding":"base64","content":"` + encoded + `"}`), nil 139 + }) 140 + 141 + content, err := client.ReadRepositoryFile(context.Background(), "prefapp/doc-daily-updates", ".github/ISSUE_TEMPLATE/daily-update.yml") 142 + if err != nil { 143 + t.Fatalf("read repository file: %v", err) 144 + } 145 + 146 + if string(content) != "title: daily update\n" { 147 + t.Fatalf("unexpected content %q", string(content)) 148 + } 149 + }
+169
internal/issueform/template.go
··· 1 + package issueform 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "gopkg.in/yaml.v3" 8 + ) 9 + 10 + type FieldType string 11 + 12 + const ( 13 + FieldMarkdown FieldType = "markdown" 14 + FieldTextarea FieldType = "textarea" 15 + FieldInput FieldType = "input" 16 + FieldCheckboxes FieldType = "checkboxes" 17 + ) 18 + 19 + type Option struct { 20 + Label string 21 + } 22 + 23 + type Field struct { 24 + Type FieldType 25 + ID string 26 + Label string 27 + Description string 28 + Placeholder string 29 + Required bool 30 + Options []Option 31 + Markdown string 32 + } 33 + 34 + type Template struct { 35 + Path string 36 + Name string 37 + Title string 38 + Fields []Field 39 + } 40 + 41 + type rawTemplate struct { 42 + Name string `yaml:"name"` 43 + Title string `yaml:"title"` 44 + Body []rawField `yaml:"body"` 45 + } 46 + 47 + type rawField struct { 48 + Type string `yaml:"type"` 49 + ID string `yaml:"id"` 50 + Attributes rawAttributes `yaml:"attributes"` 51 + Validations rawValidation `yaml:"validations"` 52 + } 53 + 54 + type rawAttributes struct { 55 + Label string `yaml:"label"` 56 + Description string `yaml:"description"` 57 + Placeholder string `yaml:"placeholder"` 58 + Value string `yaml:"value"` 59 + Options []rawOption `yaml:"options"` 60 + } 61 + 62 + type rawOption struct { 63 + Label string `yaml:"label"` 64 + } 65 + 66 + type rawValidation struct { 67 + Required bool `yaml:"required"` 68 + } 69 + 70 + func Parse(path string, data []byte) (Template, error) { 71 + var raw rawTemplate 72 + if err := yaml.Unmarshal(data, &raw); err != nil { 73 + return Template{}, fmt.Errorf("decode YAML: %w", err) 74 + } 75 + 76 + tmpl := Template{ 77 + Path: strings.TrimSpace(path), 78 + Name: strings.TrimSpace(raw.Name), 79 + Title: strings.TrimSpace(raw.Title), 80 + } 81 + 82 + for index, item := range raw.Body { 83 + field, err := parseField(item) 84 + if err != nil { 85 + return Template{}, fmt.Errorf("body[%d]: %w", index, err) 86 + } 87 + tmpl.Fields = append(tmpl.Fields, field) 88 + } 89 + 90 + if len(tmpl.EditableFields()) == 0 { 91 + return Template{}, fmt.Errorf("template has no editable fields") 92 + } 93 + 94 + return tmpl, nil 95 + } 96 + 97 + func (t Template) EditableFields() []Field { 98 + fields := make([]Field, 0, len(t.Fields)) 99 + for _, field := range t.Fields { 100 + if field.Type == FieldMarkdown { 101 + continue 102 + } 103 + fields = append(fields, field) 104 + } 105 + return fields 106 + } 107 + 108 + func (t Template) FieldByID(id string) (Field, bool) { 109 + for _, field := range t.Fields { 110 + if field.ID == id { 111 + return field, true 112 + } 113 + } 114 + return Field{}, false 115 + } 116 + 117 + func (t Template) WithAppendedField(field Field) Template { 118 + t.Fields = append(append([]Field{}, t.Fields...), field) 119 + return t 120 + } 121 + 122 + func parseField(raw rawField) (Field, error) { 123 + fieldType := FieldType(strings.TrimSpace(raw.Type)) 124 + field := Field{ 125 + Type: fieldType, 126 + ID: strings.TrimSpace(raw.ID), 127 + Label: strings.TrimSpace(raw.Attributes.Label), 128 + Description: strings.TrimSpace(raw.Attributes.Description), 129 + Placeholder: strings.TrimRight(raw.Attributes.Placeholder, "\n"), 130 + Required: raw.Validations.Required, 131 + Markdown: strings.TrimSpace(raw.Attributes.Value), 132 + } 133 + 134 + switch fieldType { 135 + case FieldMarkdown: 136 + return field, nil 137 + case FieldTextarea, FieldInput: 138 + if field.ID == "" { 139 + return Field{}, fmt.Errorf("%s field is missing id", fieldType) 140 + } 141 + if field.Label == "" { 142 + return Field{}, fmt.Errorf("%s field %q is missing label", fieldType, field.ID) 143 + } 144 + return field, nil 145 + case FieldCheckboxes: 146 + if field.ID == "" { 147 + return Field{}, fmt.Errorf("checkboxes field is missing id") 148 + } 149 + if field.Label == "" { 150 + return Field{}, fmt.Errorf("checkboxes field %q is missing label", field.ID) 151 + } 152 + for _, option := range raw.Attributes.Options { 153 + label := strings.TrimSpace(option.Label) 154 + if label == "" { 155 + continue 156 + } 157 + field.Options = append(field.Options, Option{Label: label}) 158 + } 159 + if len(field.Options) == 0 { 160 + return Field{}, fmt.Errorf("checkboxes field %q has no options", field.ID) 161 + } 162 + if len(field.Options) > 1 { 163 + return Field{}, fmt.Errorf("checkboxes field %q has %d options; pad supports one option per checkbox field", field.ID, len(field.Options)) 164 + } 165 + return field, nil 166 + default: 167 + return Field{}, fmt.Errorf("unsupported field type %q", raw.Type) 168 + } 169 + }
+72
internal/issueform/template_test.go
··· 1 + package issueform 2 + 3 + import "testing" 4 + 5 + func TestParseTemplateKeepsEditableFieldsInOrder(t *testing.T) { 6 + tmpl, err := Parse(".github/ISSUE_TEMPLATE/daily-update.yml", []byte(` 7 + name: Daily Update 8 + title: "[Daily Update] [YYYY/MM/DD]" 9 + body: 10 + - type: markdown 11 + attributes: 12 + value: | 13 + ## Daily Standup Update 14 + 15 + - type: textarea 16 + id: yesterday 17 + attributes: 18 + label: "✅ What did you do yesterday?" 19 + description: Yesterday work 20 + placeholder: | 21 + - Reviewed PR #123 22 + validations: 23 + required: true 24 + 25 + - type: checkboxes 26 + id: parking_lot 27 + attributes: 28 + label: "🚨 Do you request a Parking Lot or escalation?" 29 + options: 30 + - label: "✅ Yes, I need a Parking Lot or escalation" 31 + `)) 32 + if err != nil { 33 + t.Fatalf("parse template: %v", err) 34 + } 35 + 36 + if tmpl.Path != ".github/ISSUE_TEMPLATE/daily-update.yml" { 37 + t.Fatalf("unexpected template path %q", tmpl.Path) 38 + } 39 + 40 + editable := tmpl.EditableFields() 41 + if len(editable) != 2 { 42 + t.Fatalf("expected 2 editable fields, got %d", len(editable)) 43 + } 44 + 45 + if editable[0].ID != "yesterday" || editable[0].Type != FieldTextarea { 46 + t.Fatalf("unexpected first field %#v", editable[0]) 47 + } 48 + 49 + if editable[1].ID != "parking_lot" || editable[1].Type != FieldCheckboxes { 50 + t.Fatalf("unexpected second field %#v", editable[1]) 51 + } 52 + 53 + if editable[1].Options[0].Label != "✅ Yes, I need a Parking Lot or escalation" { 54 + t.Fatalf("unexpected checkbox option %#v", editable[1].Options) 55 + } 56 + } 57 + 58 + func TestParseTemplateRejectsCheckboxesWithMultipleOptions(t *testing.T) { 59 + _, err := Parse("daily.yml", []byte(` 60 + body: 61 + - type: checkboxes 62 + id: multi 63 + attributes: 64 + label: Pick many 65 + options: 66 + - label: One 67 + - label: Two 68 + `)) 69 + if err == nil { 70 + t.Fatalf("expected parse error") 71 + } 72 + }
+56 -167
internal/tui/editor.go
··· 10 10 tea "github.com/charmbracelet/bubbletea" 11 11 "github.com/charmbracelet/lipgloss" 12 12 "github.com/vieitesss/pad/internal/daily" 13 + "github.com/vieitesss/pad/internal/issueform" 13 14 ) 14 15 15 16 var ErrCanceled = errors.New("edit canceled") 16 17 17 - const ( 18 - yesterdayField = iota 19 - todayField 20 - blockersField 21 - parkingLotField 22 - parkingLotDetailsField 23 - additionalCommentsField 24 - ) 25 - 26 - type fieldKind int 27 - 28 - const ( 29 - textField fieldKind = iota 30 - boolField 31 - ) 32 - 33 18 type editorMode int 34 19 35 20 const ( ··· 37 22 modeCreate 38 23 ) 39 24 40 - type fieldDef struct { 41 - Title string 42 - Description string 43 - Kind fieldKind 44 - Placeholder string 45 - } 46 - 47 - var fieldDefs = []fieldDef{ 48 - { 49 - Title: "✅ What did you do yesterday?", 50 - Description: "Describe what you accomplished yesterday.", 51 - Kind: textField, 52 - Placeholder: "- Reviewed PR #123\n- Finished API changes", 53 - }, 54 - { 55 - Title: "🎯 What will you do today?", 56 - Description: "Outline your plans for today.", 57 - Kind: textField, 58 - Placeholder: "- Continue feature work\n- Write documentation", 59 - }, 60 - { 61 - Title: "🚧 Any blockers?", 62 - Description: "Optional. Mention obstacles you are facing.", 63 - Kind: textField, 64 - Placeholder: "- Waiting for code review\n- Need clarification on scope", 65 - }, 66 - { 67 - Title: "🚨 Request a Parking Lot or escalation?", 68 - Description: "Toggle this on when you need escalation or want to add a topic to the Parking Lot.", 69 - Kind: boolField, 70 - }, 71 - { 72 - Title: "📝 Parking Lot Details", 73 - Description: "Only used when escalation is enabled.", 74 - Kind: textField, 75 - Placeholder: "- Need clarification on API contract changes", 76 - }, 77 - { 78 - Title: "💬 Additional Comments", 79 - Description: "Optional extra notes or context for the team.", 80 - Kind: textField, 81 - Placeholder: "- Offline after 17:00\n- Waiting for design confirmation", 82 - }, 83 - } 84 - 85 25 var ( 86 26 headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) 87 27 paneStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).Padding(1) ··· 105 45 type model struct { 106 46 mode editorMode 107 47 entry daily.Entry 48 + fields []issueform.Field 108 49 index int 109 50 editor textarea.Model 110 51 preview viewport.Model ··· 159 100 editor.SetHeight(8) 160 101 161 102 preview := viewport.New(40, 8) 103 + fields := entry.Template.EditableFields() 162 104 163 105 m := model{ 164 106 mode: mode, 165 107 entry: entry.Normalize(), 166 - index: yesterdayField, 108 + fields: fields, 167 109 editor: editor, 168 110 preview: preview, 169 111 width: 120, ··· 211 153 return m, cmd 212 154 } 213 155 214 - if m.currentField().Kind == boolField { 156 + if m.currentField().Type == issueform.FieldCheckboxes { 215 157 switch msg.String() { 216 158 case " ", "enter": 217 - m.entry.ParkingLot = !m.entry.ParkingLot 159 + m.entry.SetChecked(m.currentField().ID, !m.entry.Checked(m.currentField().ID)) 218 160 m.refreshPreview() 219 161 return m, nil 220 162 } ··· 223 165 } 224 166 } 225 167 226 - if m.currentField().Kind != textField { 168 + if !m.currentFieldIsText() { 227 169 return m, nil 228 170 } 229 171 ··· 292 234 if m.entry.Source != "" { 293 235 headerLines = append(headerLines, mutedStyle.Render("Source: "+m.entry.Source)) 294 236 } 237 + if strings.TrimSpace(m.entry.Template.Path) != "" { 238 + headerLines = append(headerLines, mutedStyle.Render("Template: "+m.entry.Template.Path)) 239 + } 295 240 296 241 content := []string{ 297 242 lipgloss.JoinVertical(lipgloss.Left, headerLines...), ··· 311 256 } 312 257 313 258 func (m model) leftPaneView() string { 314 - fieldLines := make([]string, 0, len(fieldDefs)) 315 - for index, def := range fieldDefs { 259 + fieldLines := make([]string, 0, len(m.fields)) 260 + for index, field := range m.fields { 316 261 prefix := " " 317 262 titleStyle := mutedStyle 318 263 if index == m.index { ··· 320 265 titleStyle = currentFieldStyle 321 266 } 322 267 323 - fieldLines = append(fieldLines, fmt.Sprintf("%s%s %s", prefix, titleStyle.Render(def.Title), m.fieldStatus(index))) 268 + fieldLines = append(fieldLines, fmt.Sprintf("%s%s %s", prefix, titleStyle.Render(field.Label), m.fieldStatus(index))) 324 269 } 325 270 326 271 current := m.currentField() 327 272 editingBlock := []string{ 328 273 paneTitleStyle.Render("Template"), 329 - mutedStyle.Render("Fill the daily update template on the left. The right pane updates live."), 274 + mutedStyle.Render("Fill the current remote issue template on the left. The right pane updates live."), 330 275 "", 331 276 strings.Join(fieldLines, "\n"), 332 277 "", 333 278 paneTitleStyle.Render("Editing"), 334 - currentFieldStyle.Render(current.Title), 335 - mutedStyle.Render(current.Description), 336 - "", 279 + currentFieldStyle.Render(current.Label), 280 + } 281 + if current.Description != "" { 282 + editingBlock = append(editingBlock, mutedStyle.Render(current.Description)) 337 283 } 284 + editingBlock = append(editingBlock, "") 338 285 339 - if current.Kind == boolField { 286 + if current.Type == issueform.FieldCheckboxes { 340 287 value := "No" 341 - if m.entry.ParkingLot { 288 + if m.entry.Checked(current.ID) { 342 289 value = "Yes" 343 290 } 344 291 editingBlock = append(editingBlock, ··· 385 332 return lipgloss.JoinVertical(lipgloss.Left, lines...) 386 333 } 387 334 388 - primaryKeyStyle := actionKeyStyle 389 335 primaryDescription := "save" 390 336 if m.mode == modeCreate { 391 337 primaryDescription = "create" ··· 397 343 helpDividerStyle.Render(" • "), 398 344 helpItem(navKeyStyle, "shift+tab", "previous field"), 399 345 helpDividerStyle.Render(" • "), 400 - helpItem(primaryKeyStyle, "ctrl+s", primaryDescription), 346 + helpItem(actionKeyStyle, "ctrl+s", primaryDescription), 401 347 helpDividerStyle.Render(" • "), 402 348 helpItem(previewKeyStyle, "pgup/pgdn", "scroll preview"), 403 349 helpDividerStyle.Render(" • "), ··· 421 367 422 368 func (m *model) move(step int) { 423 369 m.persistCurrentField() 424 - m.index = nextVisibleIndex(m.index, step, m.entry.ParkingLot) 370 + m.index = nextVisibleIndex(m.index, step, len(m.fields)) 425 371 m.syncEditor() 426 372 m.refreshPreview() 427 373 } 428 374 429 375 func (m *model) syncEditor() { 430 - if m.currentField().Kind != textField { 376 + if !m.currentFieldIsText() { 431 377 m.editor.Blur() 432 378 return 433 379 } 434 380 435 381 m.editor.Focus() 436 382 m.editor.Placeholder = m.currentField().Placeholder 437 - m.editor.SetValue(m.storedTextValue(m.index)) 383 + m.editor.SetValue(m.entry.Text(m.currentField().ID)) 438 384 m.resizeEditor() 439 385 } 440 386 ··· 477 423 } 478 424 479 425 func (m *model) persistCurrentField() { 480 - if m.currentField().Kind != textField { 426 + if !m.currentFieldIsText() { 481 427 return 482 428 } 483 429 484 - m.setTextValue(m.index, m.editor.Value()) 430 + m.entry.SetText(m.currentField().ID, m.editor.Value()) 485 431 } 486 432 487 433 func (m model) finalEntry() daily.Entry { 488 434 entry := m.entry 489 - if m.currentField().Kind == textField { 490 - switch m.index { 491 - case yesterdayField: 492 - entry.Yesterday = m.editor.Value() 493 - case todayField: 494 - entry.Today = m.editor.Value() 495 - case blockersField: 496 - entry.Blockers = m.editor.Value() 497 - case parkingLotDetailsField: 498 - entry.ParkingLotDetails = m.editor.Value() 499 - case additionalCommentsField: 500 - entry.AdditionalComments = m.editor.Value() 501 - } 435 + if m.currentFieldIsText() { 436 + entry.SetText(m.currentField().ID, m.editor.Value()) 502 437 } 503 - 504 - if !entry.ParkingLot { 505 - entry.ParkingLotDetails = "" 506 - } 507 - 508 438 return entry.Normalize() 509 439 } 510 440 511 - func (m model) currentField() fieldDef { 512 - return fieldDefs[m.index] 441 + func (m model) currentField() issueform.Field { 442 + return m.fields[m.index] 513 443 } 514 444 515 - func (m model) fieldStatus(index int) string { 516 - var status string 517 - var style lipgloss.Style 445 + func (m model) currentFieldIsText() bool { 446 + switch m.currentField().Type { 447 + case issueform.FieldTextarea, issueform.FieldInput: 448 + return true 449 + default: 450 + return false 451 + } 452 + } 518 453 519 - switch index { 520 - case parkingLotField: 521 - if m.entry.ParkingLot { 522 - status = "[yes]" 523 - style = statusFilledStyle 524 - } else { 525 - status = "[no]" 526 - style = statusEmptyStyle 527 - } 528 - return style.Render(status) 529 - case parkingLotDetailsField: 530 - if !m.entry.ParkingLot { 531 - return statusDisabledStyle.Render("[disabled]") 454 + func (m model) fieldStatus(index int) string { 455 + field := m.fields[index] 456 + if field.Type == issueform.FieldCheckboxes { 457 + if m.entry.Checked(field.ID) { 458 + return statusFilledStyle.Render("[yes]") 532 459 } 460 + return statusEmptyStyle.Render("[no]") 533 461 } 534 462 535 463 if strings.TrimSpace(m.textValue(index)) == "" { ··· 540 468 } 541 469 542 470 func (m model) textValue(index int) string { 543 - if index == m.index && m.currentField().Kind == textField { 471 + field := m.fields[index] 472 + if index == m.index && m.currentFieldIsText() { 544 473 return m.editor.Value() 545 474 } 546 475 547 - return m.storedTextValue(index) 548 - } 549 - 550 - func (m model) storedTextValue(index int) string { 551 - 552 - switch index { 553 - case yesterdayField: 554 - return m.entry.Yesterday 555 - case todayField: 556 - return m.entry.Today 557 - case blockersField: 558 - return m.entry.Blockers 559 - case parkingLotDetailsField: 560 - return m.entry.ParkingLotDetails 561 - case additionalCommentsField: 562 - return m.entry.AdditionalComments 563 - default: 564 - return "" 565 - } 566 - } 567 - 568 - func (m *model) setTextValue(index int, value string) { 569 - switch index { 570 - case yesterdayField: 571 - m.entry.Yesterday = value 572 - case todayField: 573 - m.entry.Today = value 574 - case blockersField: 575 - m.entry.Blockers = value 576 - case parkingLotDetailsField: 577 - m.entry.ParkingLotDetails = value 578 - case additionalCommentsField: 579 - m.entry.AdditionalComments = value 580 - } 476 + return m.entry.Text(field.ID) 581 477 } 582 478 583 479 func (m model) splitLayout() bool { ··· 664 560 return height 665 561 } 666 562 667 - func nextVisibleIndex(current, step int, parkingLot bool) int { 668 - candidate := current + step 563 + func nextVisibleIndex(current, step, total int) int { 564 + if total <= 0 { 565 + return 0 566 + } 669 567 670 - // Wrap around boundaries 568 + candidate := current + step 671 569 if candidate < 0 { 672 - candidate = len(fieldDefs) - 1 673 - } else if candidate >= len(fieldDefs) { 674 - candidate = 0 570 + return total - 1 675 571 } 676 - 677 - // Skip parking lot details if disabled 678 - if candidate == parkingLotDetailsField && !parkingLot { 679 - // Continue in same direction, but prevent infinite loop 680 - if step == 0 { 681 - step = 1 682 - } 683 - return nextVisibleIndex(candidate, step, parkingLot) 572 + if candidate >= total { 573 + return 0 684 574 } 685 - 686 575 return candidate 687 576 }
+56 -61
internal/tui/editor_test.go
··· 5 5 "testing" 6 6 7 7 "github.com/vieitesss/pad/internal/daily" 8 + "github.com/vieitesss/pad/internal/issueform" 8 9 ) 9 10 10 11 func TestNextVisibleIndexWrapsForwardFromLastField(t *testing.T) { 11 - lastField := len(fieldDefs) - 1 12 - next := nextVisibleIndex(lastField, 1, false) 12 + next := nextVisibleIndex(2, 1, 3) 13 13 if next != 0 { 14 14 t.Fatalf("expected wrap to field 0 from last field, got %d", next) 15 15 } 16 16 } 17 17 18 18 func TestNextVisibleIndexWrapsBackwardFromFirstField(t *testing.T) { 19 - next := nextVisibleIndex(0, -1, false) 20 - lastField := len(fieldDefs) - 1 21 - if next != lastField { 22 - t.Fatalf("expected wrap to field %d from first field, got %d", lastField, next) 19 + next := nextVisibleIndex(0, -1, 3) 20 + if next != 2 { 21 + t.Fatalf("expected wrap to field 2 from first field, got %d", next) 23 22 } 24 23 } 25 24 26 - func TestNextVisibleIndexSkipsParkingLotDetailsWhenDisabledForward(t *testing.T) { 27 - next := nextVisibleIndex(parkingLotField, 1, false) 28 - if next != additionalCommentsField { 29 - t.Fatalf("expected skip to field %d, got %d", additionalCommentsField, next) 30 - } 31 - } 25 + func TestPreviewContentContainsRenderedTemplate(t *testing.T) { 26 + entry := daily.New("2026-04-16", mustTemplate(t)) 27 + entry.SetText("yesterday", "- Reviewed PR #42") 28 + entry.SetText("today", "- Continue feature work") 32 29 33 - func TestNextVisibleIndexSkipsParkingLotDetailsWhenDisabledBackward(t *testing.T) { 34 - next := nextVisibleIndex(additionalCommentsField, -1, false) 35 - if next != parkingLotField { 36 - t.Fatalf("expected skip back to field %d, got %d", parkingLotField, next) 37 - } 38 - } 39 - 40 - func TestNextVisibleIndexIncludesParkingLotDetailsWhenEnabled(t *testing.T) { 41 - next := nextVisibleIndex(parkingLotField, 1, true) 42 - if next != parkingLotDetailsField { 43 - t.Fatalf("expected next field %d, got %d", parkingLotDetailsField, next) 44 - } 45 - } 46 - 47 - func TestFinalEntryClearsParkingLotDetailsWhenDisabled(t *testing.T) { 48 - m := newModel(daily.Entry{ 49 - Date: "2026-04-16", 50 - ParkingLot: false, 51 - ParkingLotDetails: "should be cleared", 52 - }, modeSave) 53 - 54 - entry := m.finalEntry() 55 - if entry.ParkingLotDetails != "" { 56 - t.Fatalf("expected parking lot details to be cleared, got %q", entry.ParkingLotDetails) 57 - } 58 - } 59 - 60 - func TestFinalEntryDoesNotMutateParkingLotDetailsInModel(t *testing.T) { 61 - m := newModel(daily.Entry{ 62 - Date: "2026-04-16", 63 - ParkingLot: false, 64 - ParkingLotDetails: "keep me during editing", 65 - }, modeCreate) 66 - 67 - _ = m.finalEntry() 68 - if m.entry.ParkingLotDetails != "keep me during editing" { 69 - t.Fatalf("expected editor state to keep parking lot details, got %q", m.entry.ParkingLotDetails) 70 - } 71 - } 72 - 73 - func TestPreviewContentContainsRenderedTemplate(t *testing.T) { 74 - content := previewContent(daily.Entry{ 75 - Date: "2026-04-16", 76 - Yesterday: "- Reviewed PR #42", 77 - Today: "- Continue feature work", 78 - }) 30 + content := previewContent(entry) 79 31 80 32 checks := []string{ 81 33 "[Daily Update] [2026/04/16]", 82 - "## ✅ What did you do yesterday?", 83 - "## 🎯 What will you do today?", 34 + "## ✅ What did you do yesterday? <!-- pad:id:yesterday -->", 35 + "## 🎯 What will you do today? <!-- pad:id:today -->", 84 36 } 85 37 86 38 for _, check := range checks { ··· 91 43 } 92 44 93 45 func TestMoveLoadsStoredTextForEachField(t *testing.T) { 94 - m := newModel(daily.Entry{Date: "2026-04-16"}, modeCreate) 46 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 95 47 m.editor.SetValue("- first field text") 96 48 m.persistCurrentField() 97 49 ··· 108 60 t.Fatalf("expected previous field text to be restored, got %q", got) 109 61 } 110 62 } 63 + 64 + func TestCheckboxFieldTogglesInPreview(t *testing.T) { 65 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 66 + m.move(2) 67 + if m.currentField().ID != "parking_lot" { 68 + t.Fatalf("expected current field parking_lot, got %q", m.currentField().ID) 69 + } 70 + 71 + m.entry.SetChecked("parking_lot", true) 72 + content := previewContent(m.finalEntry()) 73 + if !strings.Contains(content, "- [x] ✅ Yes, I need a Parking Lot or escalation") { 74 + t.Fatalf("expected preview to show checked checkbox, got %q", content) 75 + } 76 + } 77 + 78 + func mustTemplate(t *testing.T) issueform.Template { 79 + t.Helper() 80 + tmpl, err := issueform.Parse(".github/ISSUE_TEMPLATE/daily-update.yml", []byte(` 81 + body: 82 + - type: textarea 83 + id: yesterday 84 + attributes: 85 + label: "✅ What did you do yesterday?" 86 + placeholder: "- Reviewed PR #123" 87 + 88 + - type: textarea 89 + id: today 90 + attributes: 91 + label: "🎯 What will you do today?" 92 + placeholder: "- Continue feature work" 93 + 94 + - type: checkboxes 95 + id: parking_lot 96 + attributes: 97 + label: "🚨 Do you request a Parking Lot or escalation?" 98 + options: 99 + - label: "✅ Yes, I need a Parking Lot or escalation" 100 + `)) 101 + if err != nil { 102 + t.Fatalf("parse template: %v", err) 103 + } 104 + return tmpl 105 + }
+158
internal/tui/spinner.go
··· 1 + package tui 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "os" 8 + 9 + "github.com/charmbracelet/bubbles/spinner" 10 + tea "github.com/charmbracelet/bubbletea" 11 + "github.com/charmbracelet/lipgloss" 12 + ) 13 + 14 + // RunWithSpinner runs a function with a loading spinner and returns the result. 15 + // It shows the spinner in the terminal while the operation is in progress. 16 + // If the terminal doesn't support interactive output, it runs without the spinner. 17 + func RunWithSpinner[T any](ctx context.Context, message string, fn func(context.Context) (T, error)) (T, error) { 18 + // Check if we're in a terminal 19 + if !isTerminal() { 20 + // Non-interactive mode: just run the function 21 + return fn(ctx) 22 + } 23 + 24 + m := newSpinnerModel(message, fn) 25 + p := tea.NewProgram(m, tea.WithContext(ctx)) 26 + 27 + result, err := p.Run() 28 + if err != nil { 29 + // If we can't run the program (e.g., no TTY), just run the function directly 30 + if isNoTTYError(err) { 31 + return fn(ctx) 32 + } 33 + var zero T 34 + return zero, err 35 + } 36 + 37 + finalModel := result.(spinnerModel[T]) 38 + if finalModel.err != nil { 39 + var zero T 40 + return zero, finalModel.err 41 + } 42 + 43 + return finalModel.result, nil 44 + } 45 + 46 + // RunWithSpinnerNoResult runs a function with a loading spinner for operations without a return value. 47 + func RunWithSpinnerNoResult(ctx context.Context, message string, fn func(context.Context) error) error { 48 + wrapper := func(ctx context.Context) (struct{}, error) { 49 + return struct{}{}, fn(ctx) 50 + } 51 + 52 + _, err := RunWithSpinner(ctx, message, wrapper) 53 + return err 54 + } 55 + 56 + // isTerminal checks if stdout is a terminal 57 + func isTerminal() bool { 58 + fileInfo, err := os.Stdout.Stat() 59 + if err != nil { 60 + return false 61 + } 62 + return fileInfo.Mode()&os.ModeCharDevice != 0 63 + } 64 + 65 + // isNoTTYError checks if an error is related to missing TTY 66 + func isNoTTYError(err error) bool { 67 + if err == nil { 68 + return false 69 + } 70 + errStr := err.Error() 71 + return contains(errStr, "tty") || contains(errStr, "terminal") || contains(errStr, "device") 72 + } 73 + 74 + func contains(s, substr string) bool { 75 + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || containsAt(s, substr, 0)) 76 + } 77 + 78 + func containsAt(s, substr string, start int) bool { 79 + for i := start; i <= len(s)-len(substr); i++ { 80 + if s[i:i+len(substr)] == substr { 81 + return true 82 + } 83 + } 84 + return false 85 + } 86 + 87 + // PrintSimpleProgress prints a simple progress message for non-interactive use 88 + func PrintSimpleProgress(w io.Writer, message string) { 89 + fmt.Fprintf(w, "%s...\n", message) 90 + } 91 + 92 + // spinnerModel is a tea.Model that runs a spinner while executing work 93 + type spinnerModel[T any] struct { 94 + spinner spinner.Model 95 + message string 96 + done bool 97 + result T 98 + err error 99 + workFn func(context.Context) (T, error) 100 + } 101 + 102 + type workDoneMsg[T any] struct { 103 + result T 104 + err error 105 + } 106 + 107 + func newSpinnerModel[T any](message string, workFn func(context.Context) (T, error)) spinnerModel[T] { 108 + s := spinner.New() 109 + s.Spinner = spinner.Dot 110 + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("86")) 111 + 112 + return spinnerModel[T]{ 113 + spinner: s, 114 + message: message, 115 + workFn: workFn, 116 + } 117 + } 118 + 119 + func (m spinnerModel[T]) Init() tea.Cmd { 120 + return tea.Batch( 121 + m.spinner.Tick, 122 + func() tea.Msg { 123 + ctx := context.Background() 124 + result, err := m.workFn(ctx) 125 + return workDoneMsg[T]{result: result, err: err} 126 + }, 127 + ) 128 + } 129 + 130 + func (m spinnerModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 131 + switch msg := msg.(type) { 132 + case tea.KeyMsg: 133 + switch msg.String() { 134 + case "ctrl+c": 135 + return m, tea.Quit 136 + } 137 + 138 + case workDoneMsg[T]: 139 + m.done = true 140 + m.result = msg.result 141 + m.err = msg.err 142 + return m, tea.Quit 143 + 144 + case spinner.TickMsg: 145 + var cmd tea.Cmd 146 + m.spinner, cmd = m.spinner.Update(msg) 147 + return m, cmd 148 + } 149 + 150 + return m, nil 151 + } 152 + 153 + func (m spinnerModel[T]) View() string { 154 + if m.done { 155 + return "" 156 + } 157 + return fmt.Sprintf("%s %s", m.spinner.View(), lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Render(m.message)) 158 + }
+66
internal/tui/spinner_test.go
··· 1 + package tui 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestRunWithSpinnerReturnsResult(t *testing.T) { 11 + result, err := RunWithSpinner(context.Background(), "Testing spinner", func(ctx context.Context) (string, error) { 12 + return "success", nil 13 + }) 14 + 15 + if err != nil { 16 + t.Fatalf("unexpected error: %v", err) 17 + } 18 + if result != "success" { 19 + t.Fatalf("expected result 'success', got %q", result) 20 + } 21 + } 22 + 23 + func TestRunWithSpinnerReturnsError(t *testing.T) { 24 + expectedErr := errors.New("test error") 25 + _, err := RunWithSpinner(context.Background(), "Testing spinner", func(ctx context.Context) (string, error) { 26 + return "", expectedErr 27 + }) 28 + 29 + if err != expectedErr { 30 + t.Fatalf("expected error %v, got %v", expectedErr, err) 31 + } 32 + } 33 + 34 + func TestRunWithSpinnerNoResult(t *testing.T) { 35 + called := false 36 + err := RunWithSpinnerNoResult(context.Background(), "Testing spinner", func(ctx context.Context) error { 37 + called = true 38 + return nil 39 + }) 40 + 41 + if err != nil { 42 + t.Fatalf("unexpected error: %v", err) 43 + } 44 + if !called { 45 + t.Fatal("expected function to be called") 46 + } 47 + } 48 + 49 + func TestRunWithSpinnerRespectsContext(t *testing.T) { 50 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) 51 + defer cancel() 52 + 53 + _, err := RunWithSpinner(ctx, "Testing spinner", func(ctx context.Context) (string, error) { 54 + // Simulate long-running work 55 + select { 56 + case <-time.After(100 * time.Millisecond): 57 + return "completed", nil 58 + case <-ctx.Done(): 59 + return "", ctx.Err() 60 + } 61 + }) 62 + 63 + if err == nil { 64 + t.Fatal("expected context deadline exceeded error") 65 + } 66 + }