grokkit/internal/workon/workon.go

223 lines
6.2 KiB
Go
Raw Normal View History

package workon
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gmgauthier.com/grokkit/config"
"gmgauthier.com/grokkit/internal/grok"
"gmgauthier.com/grokkit/internal/logger"
"gmgauthier.com/grokkit/internal/todo"
)
// Run executes the full transactional workon flow per todo/doing/workon.md spec.
func Run(title, customMsg string, isFix, isComplete bool) error {
if title == "" {
return fmt.Errorf("todo_item_title is required")
}
logger.Info("workon starting", "title", title, "fix", isFix, "complete", isComplete)
// 1. Bootstrap todo structure if missing
if err := todo.Bootstrap(); err != nil {
return fmt.Errorf("todo bootstrap failed: %w", err)
}
if isComplete {
return completeItem(title, isFix)
}
// 2. Handle todo or fix mode
branchPrefix := "feature/"
if isFix {
branchPrefix = "fix/"
}
branchName := branchPrefix + title
mdPath := filepath.Join("todo", "doing", title+".md")
if isFix {
if err := createFixFile(mdPath, title); err != nil {
return fmt.Errorf("create fix file failed: %w", err)
}
} else {
if err := moveQueuedToDoing(title); err != nil {
return fmt.Errorf("move to doing failed: %w", err)
}
}
// 3. Create git branch (safe)
if err := createGitBranch(branchName); err != nil {
return fmt.Errorf("failed to create branch %s: %w", branchName, err)
}
// 4. Generate + append Work Plan via Grok
if err := appendWorkPlan(mdPath, title); err != nil {
return fmt.Errorf("failed to generate/append Work Plan: %w", err)
}
// 5. Commit
commitMsg := customMsg
if commitMsg == "" {
commitMsg = fmt.Sprintf("Start working on %s", title)
}
if err := commitChanges(commitMsg); err != nil {
return fmt.Errorf("commit failed: %w", err)
}
// 6. Optional post-steps (graceful)
runCnaddIfAvailable(title)
openIDEIfConfigured()
logger.Info("workon transaction complete", "branch", branchName, "mode", map[bool]string{true: "fix", false: "todo"}[isFix])
return nil
}
func moveQueuedToDoing(title string) error {
src := filepath.Join("todo", "queued", title+".md")
dst := filepath.Join("todo", "doing", title+".md")
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("move %s -> doing failed: %w", title, err)
}
return nil
}
func createFixFile(path, title string) error {
content := fmt.Sprintf("# %s\n\n## Work Plan\n\n", title)
return os.WriteFile(path, []byte(content), 0644)
}
func createGitBranch(name string) error {
// Safe: if branch already exists, just checkout it; else create new
if err := exec.Command("git", "checkout", name).Run(); err == nil {
return nil
}
return exec.Command("git", "checkout", "-b", name).Run()
}
func appendWorkPlan(path, title string) error {
content := readFileContent(path)
prompt := fmt.Sprintf(`You are helping implement a todo item titled "%s".
Here is the current markdown content of the todo/fix file:
%s
Generate a concise, actionable **Work Plan** section.
Use numbered steps. Be specific to this item. Include testing and commit notes where relevant.
Output ONLY the markdown starting with "## Work Plan" no extra text, no introduction.`, title, content)
// Real Grok call using the project's standard client (StreamSilent for clean output)
client := grok.NewClient()
model := config.GetModel("workon", "")
plan := client.StreamSilent([]map[string]string{
{"role": "system", "content": "You are a precise software engineering assistant."},
{"role": "user", "content": prompt},
}, model) // or pull model from config/env if available
// Append the plan
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil {
logger.Error("failed to close todo file", "err", cerr)
}
}()
_, err = f.WriteString(plan)
return err
}
func readFileContent(path string) string {
b, _ := os.ReadFile(path)
return string(b)
}
func commitChanges(msg string) error {
if err := exec.Command("git", "add", "todo/").Run(); err != nil {
return err
}
return exec.Command("git", "commit", "-m", msg).Run()
}
func completeItem(title string, isFix bool) error {
src := filepath.Join("todo", "doing", title+".md")
dst := filepath.Join("todo", "completed", title+".md")
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("move to completed failed: %w", err)
}
if !isFix {
if err := updateReadmeIndex(title); err != nil {
logger.Error("failed to update README index", "err", err)
}
}
commitMsg := fmt.Sprintf("Complete work on %s", title)
if err := commitChanges(commitMsg); err != nil {
return fmt.Errorf("complete commit failed: %w", err)
}
return nil
}
func updateReadmeIndex(title string) error {
readmePath := filepath.Join("todo", "README.md")
data, err := os.ReadFile(readmePath)
if err != nil {
return fmt.Errorf("read README: %w", err)
}
lines := strings.Split(string(data), "\n")
var result []string
completedIdx := -1
for i, line := range lines {
// Skip the line referencing this title in the Queued section
if strings.Contains(line, title+".md") && strings.Contains(line, "queued/") {
continue
}
// Track the last line in the Completed section
if strings.HasPrefix(line, "## Completed") {
completedIdx = i
}
result = append(result, line)
}
// Find insertion point: after last non-empty line following ## Completed
if completedIdx >= 0 {
insertIdx := completedIdx + 1
for insertIdx < len(result) && (strings.HasPrefix(result[insertIdx], "*") || strings.TrimSpace(result[insertIdx]) == "") {
if strings.HasPrefix(result[insertIdx], "*") {
insertIdx++
} else {
break
}
}
entry := fmt.Sprintf("* [%s](./completed/%s.md) : %s *(done)*", title, title, title)
result = append(result[:insertIdx], append([]string{entry}, result[insertIdx:]...)...)
}
return os.WriteFile(readmePath, []byte(strings.Join(result, "\n")), 0644)
}
func runCnaddIfAvailable(title string) {
if _, err := exec.LookPath("cnadd"); err == nil {
_ = exec.Command("cnadd", "log", fmt.Sprintf("started work on %s", title)).Run()
}
}
func openIDEIfConfigured() {
ideCmd := config.GetIDECommand()
if ideCmd == "" {
logger.Debug("IDE open skipped (no IDE configured)")
return
}
if err := exec.Command(ideCmd, ".").Start(); err != nil {
logger.Error("failed to open IDE", "cmd", ideCmd, "err", err)
}
}