mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
146 lines
4.1 KiB
Go
146 lines
4.1 KiB
Go
package pipe
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/boolean-maybe/tiki/config"
|
|
"github.com/boolean-maybe/tiki/internal/bootstrap"
|
|
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
|
"github.com/boolean-maybe/tiki/service"
|
|
)
|
|
|
|
// IsPipedInput reports whether stdin is connected to a pipe or redirected file
|
|
// rather than a terminal (character device).
|
|
func IsPipedInput() bool {
|
|
fi, err := os.Stdin.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return fi.Mode()&os.ModeCharDevice == 0
|
|
}
|
|
|
|
// HasPositionalArgs reports whether args contains any non-flag positional arguments.
|
|
// It skips known flag-value pairs (e.g. --log-level debug) so that only real
|
|
// positional arguments like file paths, "-", "init", etc. are detected.
|
|
func HasPositionalArgs(args []string) bool {
|
|
skipNext := false
|
|
for _, arg := range args {
|
|
if skipNext {
|
|
skipNext = false
|
|
continue
|
|
}
|
|
if arg == "--" {
|
|
return true // everything after "--" is positional
|
|
}
|
|
// Bare "-" means "read stdin for the viewer", treat as positional
|
|
if arg == "-" {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(arg, "-") {
|
|
if arg == "--log-level" {
|
|
skipNext = true
|
|
}
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// CreateTaskFromReader reads piped input, parses it into title/description,
|
|
// and creates a new tiki task. Returns the task ID (e.g. "TIKI-ABC123").
|
|
func CreateTaskFromReader(r io.Reader) (string, error) {
|
|
// Suppress info/debug logs for the non-interactive pipe path.
|
|
// The pipe path bypasses bootstrap (which normally configures logging),
|
|
// so the default slog handler would write INFO+ messages to stderr.
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
|
Level: slog.LevelError,
|
|
})))
|
|
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read input: %w", err)
|
|
}
|
|
|
|
input := strings.TrimSpace(string(data))
|
|
if input == "" {
|
|
return "", fmt.Errorf("empty input: title is required")
|
|
}
|
|
|
|
title, description := parseInput(input)
|
|
|
|
if err := bootstrap.EnsureGitRepo(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !config.IsProjectInitialized() {
|
|
return "", fmt.Errorf("project not initialized: run 'tiki init' first")
|
|
}
|
|
|
|
// Load status definitions before creating tasks
|
|
if err := config.LoadStatusRegistry(); err != nil {
|
|
return "", fmt.Errorf("load status registry: %w", err)
|
|
}
|
|
|
|
gate := service.BuildGate()
|
|
|
|
_, taskStore, err := bootstrap.InitStores()
|
|
if err != nil {
|
|
return "", fmt.Errorf("initialize store: %w", err)
|
|
}
|
|
gate.SetStore(taskStore)
|
|
|
|
// load triggers so piped creates fire them
|
|
schema := rukiRuntime.NewSchema()
|
|
userName, _, _ := taskStore.GetCurrentUser()
|
|
if _, loadErr := service.LoadAndRegisterTriggers(gate, schema, func() string { return userName }); loadErr != nil {
|
|
return "", fmt.Errorf("load triggers: %w", loadErr)
|
|
}
|
|
|
|
task, err := taskStore.NewTaskTemplate()
|
|
if err != nil {
|
|
return "", fmt.Errorf("create task template: %w", err)
|
|
}
|
|
|
|
task.Title = title
|
|
task.Description = description
|
|
|
|
if err := gate.CreateTask(context.Background(), task); err != nil {
|
|
return "", fmt.Errorf("create task: %w", err)
|
|
}
|
|
|
|
return task.ID, nil
|
|
}
|
|
|
|
// parseInput splits piped text into title and description.
|
|
// If the first line is a markdown heading (e.g. "# Title"), the '#' prefix is
|
|
// stripped for the title and the description contains the entire original input.
|
|
// Otherwise: first line is the title, everything after is the description.
|
|
func parseInput(input string) (title, description string) {
|
|
first, rest, found := strings.Cut(input, "\n")
|
|
title = strings.TrimSpace(first)
|
|
|
|
if heading, ok := stripMarkdownHeading(title); ok {
|
|
return heading, strings.TrimSpace(input)
|
|
}
|
|
|
|
if !found {
|
|
return title, ""
|
|
}
|
|
return title, strings.TrimSpace(rest)
|
|
}
|
|
|
|
// stripMarkdownHeading returns the heading text without the leading '#' chars
|
|
// if line is a valid ATX heading (one or more '#' followed by a space).
|
|
func stripMarkdownHeading(line string) (string, bool) {
|
|
trimmed := strings.TrimLeft(line, "#")
|
|
if trimmed == line || !strings.HasPrefix(trimmed, " ") {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(trimmed), true
|
|
}
|