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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cmd/docs-components/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ func (s *stringSliceFlag) Set(value string) error {
return nil
}

// main is the CLI entrypoint for generating documentation components and optionally
// rendering documentation templates via the markdown-transclusion tool.
//
// It parses command-line flags to configure repository and output paths, generator
// options (graph direction, clusters, palette and palette file), and transclusion
// settings (binary, base path, and additional args). It constructs a docs
// generator, runs component generation, and—unless -skip-transclusion is set—resolves
// the transclusion binary and arguments (from flags or MARKDOWN_TRANSCLUSION_* env
// vars) and invokes markdown-transclusion to render a set of templates to their
// configured outputs. Any initialization, generation, or rendering error causes the
// program to log a fatal error and exit.
func main() {
log.SetFlags(0)
log.SetPrefix("docs-components: ")
Expand Down Expand Up @@ -138,6 +149,9 @@ func main() {
}
}

// parseArgs splits raw into whitespace-separated fields (using strings.Fields)
// and returns a newly allocated slice containing those fields. An empty or
// all-whitespace input yields an empty slice.
func parseArgs(raw string) []string {
fields := strings.Fields(raw)
return append([]string(nil), fields...)
Expand Down
11 changes: 11 additions & 0 deletions cmd/release/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ import (
"github.com/flyingrobots/hubless/internal/release"
)

// main is the entry point for the hubless release CLI.
//
// It parses command-line flags to configure a release and invokes the releaser:
// - repo: repository root (default ".")
// - version: version to tag (required)
// - notes: path to release notes markdown (default "docs/reference/release-notes.md")
// - dry-run: show actions without creating a tag
// - skip-checks: skip fmt/lint/test/docs before tagging
//
// If --version is omitted the program prints a short message, shows usage and exits with code 2.
// Any other initialization or run error is logged and the program exits non‑zero.
func main() {
log.SetFlags(0)
log.SetPrefix("hubless-release: ")
Expand Down
64 changes: 63 additions & 1 deletion internal/docscomponents/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@
palettes map[string]map[string]paletteColor
}

// NewGenerator builds a Generator using the provided repository and components directories.
// NewGenerator creates a Generator configured to write component snippets for the repository at
// repoRoot. It resolves and stores absolute paths for repoRoot and componentsDir (defaulting
// componentsDir to repoRoot/docs/components when empty), ensures the components directory exists,
// loads the built-in palettes and merges palettes from options.PaletteFile if provided, and
// normalizes the supplied GeneratorOptions. Returns an initialized Generator or an error if input
// validation, path resolution, directory creation, or palette loading fails.
func NewGenerator(repoRoot, componentsDir string, options GeneratorOptions) (*Generator, error) {
if repoRoot == "" {
return nil, errors.New("repoRoot is required")
Expand Down Expand Up @@ -770,6 +775,12 @@
return results, nil
}

// dependencyRowsFromArtifacts builds dependency rows for artifacts.
// It examines each artifact record, cleans its Dependencies with cleanValues,
// and for records that have one or more dependencies appends a dependencyRow
// containing the artifact ID, a link formed by joining linkPrefix with the
// basename of the record's sourcePath, and the cleaned dependency list.
// Records without dependencies are omitted from the returned slice.
func dependencyRowsFromArtifacts(records []recordWithPath[artifactRecord], linkPrefix string) []dependencyRow {
rows := make([]dependencyRow, 0, len(records))
for _, record := range records {
Expand All @@ -787,6 +798,10 @@
return rows
}

// dependencyRowsFromTasks builds dependencyRow entries for each task record that has
// one or more dependencies. The returned rows contain the task ID, a link formed by
// joining linkPrefix with the task record's source file base name, and the cleaned
// list of dependency IDs. Records without dependencies are omitted.
func dependencyRowsFromTasks(records []recordWithPath[taskRecord], linkPrefix string) []dependencyRow {
rows := make([]dependencyRow, 0)
for _, record := range records {
Expand All @@ -804,6 +819,8 @@
return rows
}

// countDoneArtifacts returns the number of artifact records whose Status is considered completed.
// It uses isDoneStatus to determine completion (e.g., DONE, COMPLETED, COMPLETE, SHIPPED, case-insensitive).
func countDoneArtifacts(records []recordWithPath[artifactRecord]) int {
count := 0
for _, record := range records {
Expand All @@ -814,6 +831,7 @@
return count
}

// (e.g., DONE, COMPLETED, COMPLETE, SHIPPED).
func countDoneTasks(records []recordWithPath[taskRecord]) int {
count := 0
for _, record := range records {
Expand All @@ -830,6 +848,12 @@
Text string
}

// defaultPalettes returns the built-in color palettes used for graph node styling.
//
// The return value is a map from palette name to a map of class names
// ("milestone", "feature", "story", "task") to paletteColor. The function
// returns a deep copy of the internal base definitions so callers can modify
// the result without affecting the originals.
func defaultPalettes() map[string]map[string]paletteColor {
base := map[string]map[string]paletteColor{
"evergreen": {
Expand Down Expand Up @@ -869,6 +893,16 @@
Text string `json:"text"`
}

// mergePaletteFile reads a JSON palette file at path and merges its palette
// definitions into the provided palettes map.
//
// The file may contain multiple palette objects keyed by name; a top-level
// "$schema" key is ignored. Palette and class names are normalized to lower
// case and trimmed. Each palette class must be one of the known classes
// (milestone, feature, story, task) and must include non-empty `fill`,
// `stroke`, and `text` color values. Missing file is not an error (returns
// nil). On success the palettes map is mutated to include or update the
// palettes from the file; on failure a descriptive error is returned.
func mergePaletteFile(path string, palettes map[string]map[string]paletteColor) error {
data, err := os.ReadFile(path)
if err != nil {
Expand Down Expand Up @@ -919,6 +953,13 @@
return nil
}

// renderProgressBar returns a fixed-width textual progress bar (10 units) and a
// percentage for the given done/total pair.
//
// If total is <= 0 the function returns a zeroed bar "[----------] 0%". The
// progress ratio is clamped to [0,1]; the number of filled units is computed by
// rounding ratio*10 and the percentage is ratio*100 rounded to the nearest
// integer.
func renderProgressBar(done, total int) string {
const width = 10
if total <= 0 {
Expand All @@ -942,6 +983,8 @@
return fmt.Sprintf("[%s%s] %d%%", strings.Repeat("#", filled), strings.Repeat("-", width-filled), percent)
}

// isDoneStatus reports whether a status string represents a completed state.
// It returns true for the case-insensitive values "DONE", "COMPLETED", "COMPLETE", or "SHIPPED"; otherwise false.
func isDoneStatus(status string) bool {
switch strings.ToUpper(strings.TrimSpace(status)) {
case "DONE", "COMPLETED", "COMPLETE", "SHIPPED":
Expand All @@ -951,6 +994,7 @@
}
}

// any other or empty status -> 5
func statusRank(status string) int {
switch strings.ToUpper(strings.TrimSpace(status)) {
case "DONE", "COMPLETED", "COMPLETE", "SHIPPED":
Expand All @@ -968,6 +1012,9 @@
}
}

// cleanValues returns a new slice containing the non-empty strings from values
// after trimming surrounding whitespace. The original order is preserved.
// If values is nil or has length zero, cleanValues returns nil.
func cleanValues(values []string) []string {
if len(values) == 0 {
return nil
Expand Down Expand Up @@ -1054,14 +1101,14 @@
return nil
}

func parseTimestamp(s string) time.Time {

Check failure on line 1104 in internal/docscomponents/generator.go

View workflow job for this annotation

GitHub Actions / docs

undefined: time
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}

Check failure on line 1107 in internal/docscomponents/generator.go

View workflow job for this annotation

GitHub Actions / docs

undefined: time
}
// Try common formats (include non-padded and zero-padded forms)
layouts := []string{
time.RFC3339,

Check failure on line 1111 in internal/docscomponents/generator.go

View workflow job for this annotation

GitHub Actions / docs

undefined: time
"2006-1-2 15:04:05",
"2006-1-2 15:04",
"2006-1-2",
Expand All @@ -1070,7 +1117,7 @@
"2006-01-02",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, s); err == nil {

Check failure on line 1120 in internal/docscomponents/generator.go

View workflow job for this annotation

GitHub Actions / docs

undefined: time
return t
}
}
Expand All @@ -1080,15 +1127,15 @@
y := strings.TrimSpace(parts[0])
mStr := strings.TrimSpace(parts[1])
dStr := strings.TrimSpace(parts[2])
if mi, err1 := strconv.Atoi(mStr); err1 == nil {

Check failure on line 1130 in internal/docscomponents/generator.go

View workflow job for this annotation

GitHub Actions / docs

undefined: strconv
if di, err2 := strconv.Atoi(dStr); err2 == nil {

Check failure on line 1131 in internal/docscomponents/generator.go

View workflow job for this annotation

GitHub Actions / docs

undefined: strconv
if t, err := time.Parse("2006-01-02", fmt.Sprintf("%s-%02d-%02d", y, mi, di)); err == nil {

Check failure on line 1132 in internal/docscomponents/generator.go

View workflow job for this annotation

GitHub Actions / docs

undefined: time
return t
}
}
}
}
return time.Time{}

Check failure on line 1138 in internal/docscomponents/generator.go

View workflow job for this annotation

GitHub Actions / docs

undefined: time
}

func (g *Generator) writeFile(destPath, contents string) error {
Expand All @@ -1099,6 +1146,8 @@
return os.WriteFile(destPath, []byte(contents), 0o644)
}

// formatList trims and filters empty strings from values and returns them joined by ", ".
// If the resulting list is empty it returns an em dash "—".
func formatList(values []string) string {
cleaned := cleanValues(values)
if len(cleaned) == 0 {
Expand All @@ -1108,6 +1157,9 @@
return strings.Join(cleaned, ", ")
}

// escapeMermaidLabel returns label with characters escaped for use in Mermaid diagrams.
// It escapes backslashes, double quotes, and square brackets so the label can be safely
// embedded in Mermaid node definitions.
func escapeMermaidLabel(label string) string {
replacer := strings.NewReplacer(
"\\", "\\\\",
Expand All @@ -1118,6 +1170,7 @@
return replacer.Replace(label)
}

// the record's id; records without a corresponding entry in nodeIDs are skipped.
func collectNodesByType(typeLabel string, records []typedRecord, nodeIDs map[string]string) []string {
nodes := make([]string, 0)
for _, record := range records {
Expand All @@ -1133,6 +1186,10 @@
return nodes
}

// normalizeOptions normalizes and validates graph-related fields in a GeneratorOptions value.
// It uppercases and validates GraphDirection (defaults to "LR" if invalid) and lowercases
// GraphPalette (defaults to "evergreen" or to "evergreen" if the named palette is not present
// in the provided palettes map), returning the adjusted options.
func normalizeOptions(options GeneratorOptions, palettes map[string]map[string]paletteColor) GeneratorOptions {
direction := strings.ToUpper(strings.TrimSpace(options.GraphDirection))
if !isValidDirection(direction) {
Expand All @@ -1152,6 +1209,8 @@
return options
}

// isValidDirection reports whether the given graph direction is one of the
// supported Mermaid directions: "LR", "RL", "TB", or "BT".
func isValidDirection(direction string) bool {
switch direction {
case "LR", "RL", "TB", "BT":
Expand All @@ -1163,13 +1222,16 @@

var orderedTypes = []string{"Milestone", "Feature", "Story", "Task"}

// string if the pointer is nil.
func derefString(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}

// isKnownPaletteClass reports whether the given class name is a recognized palette
// class. Valid names are "milestone", "feature", "story", and "task".
func isKnownPaletteClass(class string) bool {
switch class {
case "milestone", "feature", "story", "task":
Expand Down
9 changes: 8 additions & 1 deletion internal/docscomponents/transclusion.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ type TransclusionOptions struct {
OutputPath string
}

// RunTransclusion executes the markdown-transclusion CLI to render a document template.
// RunTransclusion runs the markdown-transclusion CLI to render a document template
// using opts. It validates that opts.InputPath and opts.OutputPath are set, defaults
// opts.Bin to "markdown-transclusion" when empty, and uses opts.BasePath or the
// current working directory as the base. Paths are resolved to absolute values
// relative to the base, the output directory is created if necessary, and the CLI is
// executed with the provided context. On failure the returned error includes the
// CLI's combined output.
func RunTransclusion(ctx context.Context, opts TransclusionOptions) error {
if opts.InputPath == "" {
return errors.New("input path is required")
Expand Down Expand Up @@ -75,6 +81,7 @@ func RunTransclusion(ctx context.Context, opts TransclusionOptions) error {
return nil
}

// absolute path fails.
func makeAbsoluteWithBase(pathValue, base string) (string, error) {
if filepath.IsAbs(pathValue) {
return pathValue, nil
Expand Down
18 changes: 15 additions & 3 deletions internal/mock/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ type StatusSection struct {
Counter int
}

// MockCatalog returns sample issues for list/detail wireframes.
// MockCatalog returns a slice of sample Issue values used by list/detail wireframes.
// The provided now time is used to compute LastUpdated, Comment.CreatedAt, and TimelineEvent.Timestamp
// so callers can control the generated timestamps.
func MockCatalog(now time.Time) []Issue {
return []Issue{
{
Expand Down Expand Up @@ -110,7 +112,13 @@ func MockCatalog(now time.Time) []Issue {
}
}

// MockStatusSections returns the data driving the home buffer sections.
// MockStatusSections returns sample status sections used by the home buffer view.
//
// It produces four static sections — "Focus", "Inbox", "Boards", and "Saved Filters" —
// each with example items, a short hint for keyboard interaction, and a counter.
//
// The `now` parameter is accepted for API consistency with other mock factories but is
// not used to compute the returned data.
func MockStatusSections(now time.Time) []StatusSection {
return []StatusSection{
{
Expand Down Expand Up @@ -140,7 +148,11 @@ func MockStatusSections(now time.Time) []StatusSection {
}
}

// MockBoard returns fake kanban columns for the kanban view.
// MockBoard returns a deterministic set of sample Kanban columns and cards used by mock UI flows.
//
// The returned slice contains three columns ("Open", "In Progress", "Review") populated with
// BoardCard entries (ID, Title, Assignee, Priority). Intended for development and testing of
// Kanban/board views—not for production data.
func MockBoard() []BoardColumn {
return []BoardColumn{
{
Expand Down
7 changes: 7 additions & 0 deletions internal/release/releaser.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ type Releaser struct {
repoRoot string
}

// New returns a Releaser for the repository located at repoRoot.
// repoRoot must be a non-empty path; it may be relative and will be resolved
// to an absolute path. Returns an error if repoRoot is empty or the path
// cannot be resolved.
func New(repoRoot string) (*Releaser, error) {
if repoRoot == "" {
return nil, errors.New("repo root is required")
Expand Down Expand Up @@ -170,6 +174,9 @@ func (r *Releaser) capture(ctx context.Context, name string, args ...string) (st
return string(output), err
}

// normalizeVersion trims whitespace from v and ensures it begins with a "v".
// If the trimmed version is empty it is returned unchanged; otherwise, a
// leading "v" is added when missing (e.g. "1.2.3" -> "v1.2.3").
func normalizeVersion(version string) string {
version = strings.TrimSpace(version)
if version == "" {
Expand Down
9 changes: 9 additions & 0 deletions internal/ui/tui/mock/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ type AppModel struct {
styles Styles
}

// NewModel creates an AppModel initialized for a mocked TUI.
//
// The returned model is set to the Status screen, sized to the provided
// width and height, and populated with the given sections, issues, and board
// columns. The layout profile is selected based on width, default styles are
// applied, and selection indices (sectionIndex, issueIndex) start at 0.
//
// This constructor is intended for creating self-contained mock UI state used
// in layout and rendering tests.
func NewModel(width, height int, sections []mockStatusSection, issues []mockIssue, board []mockBoardColumn) AppModel {
prof := profileForWidth(width)
return AppModel{
Expand Down
3 changes: 3 additions & 0 deletions internal/ui/tui/mock/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
return p.name
}

// profileForWidth returns the responsive layoutProfile for the given width.
// It maps widths < 100 to the "sm" (small) profile, widths >= 100 and < 140 to
// the "md" (medium) profile, and widths >= 140 to the "lg" (large) profile.
// layoutProfile encapsulates responsive settings derived from Stickers breakpoints (in terminal columns).
func profileForWidth(width int) layoutProfile {
switch {
Expand All @@ -25,4 +28,4 @@
return layoutProfile{id: "lg", name: "large"}
}
}
}

Check failure on line 31 in internal/ui/tui/mock/profile.go

View workflow job for this annotation

GitHub Actions / quality

expected declaration, found '}'
5 changes: 5 additions & 0 deletions internal/ui/tui/mock/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ type Styles struct {
Footer lipgloss.Style
}

// NewStyles constructs and returns a Styles value with the mock TUI's default styling.
//
// The returned Styles sets:
// - Statusline: foreground color #00B3A4 and bold text.
// - Footer: foreground color #6B9F7F.
func NewStyles() Styles {
return Styles{
Statusline: lipgloss.NewStyle().Foreground(lipgloss.Color("#00B3A4")).Bold(true),
Expand Down
1 change: 1 addition & 0 deletions scripts/verify-docs.sh
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ TARGETS=(
missing=()
failures=()

# contains_placeholder returns 0 if the specified file contains an unresolved placeholder of the form `![[...]]`; otherwise returns 1.
contains_placeholder() {
local file="$1"
if rg -n "!\\[\\[[^]]+\\]\\]" "$file" >/dev/null; then
Expand Down
Loading