Closes the human curation loop: list brain/raw/ notes awaiting review (oldest-first, with excerpt) and promote one into brain/wiki/<wing>/<hall>/. PromoteNote rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at + custom fields via the existing frontmatter editor), rebuilds the wing _index, and runs auto-tunnel. Atomic from the caller's view: hall/wing/slug/collision validation happens before any fs change, and the source is deleted only after the destination write succeeds (write-then- delete, never move) — a collision or write failure leaves raw/ intact. Default slug = filename minus the YYYY-MM-DD- prefix. Also exposes GET /pending + POST /promote for shell scripts (bad hall/collision/missing-source → 400, not 500). Scope note: operates on raw/ (the retrospective-skill review queue) per this issue's spec. The knowledge/ legacy pile is #22's bulk-migration concern, not this ongoing queue. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
4.9 KiB
Go
157 lines
4.9 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
)
|
|
|
|
// PendingNote describes a raw/ note awaiting human promotion to the wiki.
|
|
type PendingNote struct {
|
|
Filename string `json:"filename"`
|
|
CreatedAt string `json:"created_at"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
Excerpt string `json:"excerpt"`
|
|
}
|
|
|
|
// datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when
|
|
// deriving the default promoted slug.
|
|
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`)
|
|
|
|
// ListPending returns the notes in brain/raw/ awaiting review, oldest-first
|
|
// (natural review order). An absent raw/ dir yields an empty slice, not an
|
|
// error. Only .md files are listed; tunnel-candidate files are skipped.
|
|
func ListPending(brainDir string) ([]PendingNote, error) {
|
|
dir := filepath.Join(brainDir, "raw")
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []PendingNote{}, nil
|
|
}
|
|
return nil, fmt.Errorf("read raw dir: %w", err)
|
|
}
|
|
|
|
out := make([]PendingNote, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
|
continue
|
|
}
|
|
info, statErr := e.Info()
|
|
if statErr != nil {
|
|
continue
|
|
}
|
|
raw, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if readErr != nil {
|
|
continue
|
|
}
|
|
fm, body := parseFrontmatter(string(raw))
|
|
created := fm.get("created_at")
|
|
if created == "" {
|
|
created = info.ModTime().UTC().Format(time.RFC3339)
|
|
}
|
|
out = append(out, PendingNote{
|
|
Filename: e.Name(),
|
|
CreatedAt: created,
|
|
SizeBytes: info.Size(),
|
|
Excerpt: excerpt(body, 200),
|
|
})
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
|
return out, nil
|
|
}
|
|
|
|
// PromoteOptions identifies a raw note to promote and its wiki destination.
|
|
type PromoteOptions struct {
|
|
Filename string // basename in brain/raw/
|
|
Wing string
|
|
Hall string
|
|
Slug string // optional; defaults to Filename minus date prefix + .md
|
|
}
|
|
|
|
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
|
|
// rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at and
|
|
// any custom fields), writes to brain/wiki/<wing>/<hall>/<slug>.md, deletes
|
|
// the source, then rebuilds the wing index and runs auto-tunnel detection.
|
|
//
|
|
// It is atomic from the caller's view: validation (hall, wing, slug,
|
|
// collision) happens before any filesystem change, and the source is deleted
|
|
// only after the destination write succeeds (write-then-delete, never move).
|
|
// Returns the promoted note's path relative to brainDir.
|
|
func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|
// Validate filename (basename only — no traversal) before touching fs.
|
|
base := filepath.Base(opts.Filename)
|
|
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
|
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
|
}
|
|
|
|
slug := opts.Slug
|
|
if slug == "" {
|
|
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
|
}
|
|
// NotePath validates hall + wing + slug; do this before reading anything.
|
|
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
src := filepath.Join(brainDir, "raw", base)
|
|
raw, err := os.ReadFile(src)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
|
}
|
|
return "", fmt.Errorf("read source: %w", err)
|
|
}
|
|
|
|
// Collision: never silently overwrite an existing promoted note.
|
|
if _, statErr := os.Stat(dest); statErr == nil {
|
|
rel, _ := filepath.Rel(brainDir, dest)
|
|
return "", fmt.Errorf("target %s already exists; choose a different slug", filepath.ToSlash(rel))
|
|
}
|
|
|
|
fm, body := parseFrontmatter(string(raw))
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
fm.set("wing", brain.Sanitise(opts.Wing))
|
|
fm.set("hall", opts.Hall)
|
|
if fm.get("created_at") == "" {
|
|
fm.set("created_at", now)
|
|
}
|
|
fm.set("promoted_at", now)
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
return "", fmt.Errorf("create wing dir: %w", err)
|
|
}
|
|
// Write-then-delete: the source survives any write failure.
|
|
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
|
return "", fmt.Errorf("write promoted note: %w", err)
|
|
}
|
|
if err := os.Remove(src); err != nil {
|
|
return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
|
|
}
|
|
|
|
rel, _ := filepath.Rel(brainDir, dest)
|
|
relSlash := filepath.ToSlash(rel)
|
|
|
|
// Best-effort wiki upkeep — the note is already promoted.
|
|
_ = brain.BuildWingIndex(brainDir, opts.Wing)
|
|
_ = brain.AutoTunnel(brainDir, relSlash, body)
|
|
|
|
return relSlash, nil
|
|
}
|
|
|
|
// excerpt returns the first n runes of s, trimmed, single-spaced.
|
|
func excerpt(s string, n int) string {
|
|
s = strings.TrimSpace(s)
|
|
r := []rune(s)
|
|
if len(r) > n {
|
|
r = r[:n]
|
|
}
|
|
return strings.TrimSpace(string(r))
|
|
}
|