ajhahn.de
← eeco
Go 168 lines
package workflow

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/ajhahnde/eeco/internal/gitx"
	"github.com/ajhahnde/eeco/internal/queue"
)

// handoverDir is the per-note directory inside <workspace>/docs/.
const handoverDir = "handover"

// handoverRefresh writes a dated session-handover note plus a "what
// changed since the last one" summary, then queues it for the
// maintainer to review. It never overwrites: every note is a fresh
// uniquely-stamped file, so prior handovers stay intact. The note lives
// in the gitignored workspace (write-scope floor invariant). git is
// used read-only for the change summary; when git is unavailable the
// note is still written (without the summary) rather than blocking.
type handoverRefresh struct{}

func (handoverRefresh) Name() string { return "handover-refresh" }

func (handoverRefresh) Summary() string {
	return "dated handover note + change-since-last summary; queued, never overwrites"
}

func (handoverRefresh) Run(env Env) (Result, error) {
	cfg := env.Config
	dir := filepath.Join(cfg.Workspace, "docs", handoverDir)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return Result{}, fmt.Errorf("handover-refresh: %w", err)
	}

	prevBase := lastHandoverHead(dir)

	head := ""
	changeSummary := "(git unavailable — no change summary)"
	if sha, err := gitx.HeadSHA(cfg.RepoRoot); err == nil {
		head = sha
		log, stat, cerr := gitx.ChangesSince(cfg.RepoRoot, prevBase)
		switch {
		case cerr != nil:
			changeSummary = "(change summary unavailable: " + cerr.Error() + ")"
		case prevBase == "":
			changeSummary = "first handover — no prior baseline.\n\n" + nonEmpty(log, "(no commits)")
		default:
			changeSummary = nonEmpty(log, "(no new commits)")
			if stat != "" {
				changeSummary += "\n\n" + stat
			}
		}
	} else if !errors.Is(err, gitx.ErrUnavailable) {
		changeSummary = "(change summary unavailable: " + err.Error() + ")"
	}

	stamp := time.Now().UTC()
	path := uniquePath(dir, "handover-"+stamp.Format("20060102T150405.000000000Z")+".md")
	note := buildHandoverNote(stamp, prevBase, head, changeSummary)
	if err := os.WriteFile(path, []byte(note), 0o644); err != nil {
		return Result{}, fmt.Errorf("handover-refresh: write note: %w", err)
	}

	rel := path
	if r, err := filepath.Rel(cfg.RepoRoot, path); err == nil {
		rel = filepath.ToSlash(r)
	}
	if err := queue.Append(filepath.Join(cfg.Workspace, "state"), queue.Item{
		Kind:    "handover",
		Title:   "Handover note ready for review",
		Project: filepath.Base(cfg.RepoRoot),
		Detail:  "wrote " + rel + " — review and carry forward what is still relevant",
		Date:    stamp,
	}); err != nil {
		return Result{}, fmt.Errorf("handover-refresh: queue: %w", err)
	}

	return Result{
		Code:    CodeClean,
		Summary: "handover note written and queued for review (" + rel + ")",
	}, nil
}

// lastHandoverHead returns the head SHA recorded in the most recent
// existing note, or "" when there is none. The timestamped filenames
// sort lexically in chronological order.
func lastHandoverHead(dir string) string {
	ents, err := os.ReadDir(dir)
	if err != nil {
		return ""
	}
	var names []string
	for _, e := range ents {
		if !e.IsDir() && strings.HasPrefix(e.Name(), "handover-") && strings.HasSuffix(e.Name(), ".md") {
			names = append(names, e.Name())
		}
	}
	if len(names) == 0 {
		return ""
	}
	sort.Strings(names)
	b, err := os.ReadFile(filepath.Join(dir, names[len(names)-1]))
	if err != nil {
		return ""
	}
	for _, line := range strings.Split(string(b), "\n") {
		if v, ok := strings.CutPrefix(strings.TrimSpace(line), "head:"); ok {
			return strings.TrimSpace(v)
		}
	}
	return ""
}

// uniquePath returns base inside dir, or base with a numeric suffix if
// it already exists, so a note is never overwritten.
func uniquePath(dir, base string) string {
	path := filepath.Join(dir, base)
	if _, err := os.Stat(path); os.IsNotExist(err) {
		return path
	}
	ext := filepath.Ext(base)
	stem := strings.TrimSuffix(base, ext)
	for i := 2; ; i++ {
		cand := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, i, ext))
		if _, err := os.Stat(cand); os.IsNotExist(err) {
			return cand
		}
	}
}

func nonEmpty(s, fallback string) string {
	if strings.TrimSpace(s) == "" {
		return fallback
	}
	return s
}

func buildHandoverNote(stamp time.Time, base, head, changes string) string {
	if base == "" {
		base = "none"
	}
	if head == "" {
		head = "unknown"
	}
	return fmt.Sprintf(`# Handover — %s

Written by eeco run handover-refresh. This note is a draft for the
maintainer; nothing here is committed.

base: %s
head: %s

## Changes since last handover

%s

## Open threads

(carry forward what is still relevant; delete the rest)
`, stamp.Format(time.RFC3339), base, head, changes)
}