179 lines
5.1 KiB
Go
179 lines
5.1 KiB
Go
package legacyimport
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
aoprocess "github.com/aoagents/agent-orchestrator/backend/internal/process"
|
|
)
|
|
|
|
// Store is the narrow slice of the rewrite's native storage layer the importer
|
|
// writes through. *sqlite.Store satisfies it. Idempotency lives here: a project
|
|
// whose id already exists is skipped, never overwritten, so a re-run is safe
|
|
// and legacy files stay the sole source of truth.
|
|
type Store interface {
|
|
GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
|
|
UpsertProject(ctx context.Context, r domain.ProjectRecord) error
|
|
}
|
|
|
|
// Options configure one import run.
|
|
type Options struct {
|
|
// Root is the legacy state root to read (default ~/.agent-orchestrator).
|
|
Root string
|
|
// DryRun parses + plans every row but writes nothing.
|
|
DryRun bool
|
|
// Now is the fallback registered_at timestamp. Zero -> time.Now().UTC().
|
|
Now time.Time
|
|
// RepoOriginURL resolves a repo's git origin. Nil -> the real git resolver.
|
|
RepoOriginURL func(path string) string
|
|
}
|
|
|
|
// Report is the structured outcome of an import run.
|
|
type Report struct {
|
|
DryRun bool `json:"dryRun"`
|
|
ProjectsImported int `json:"projectsImported"`
|
|
ProjectsSkipped int `json:"projectsSkipped"` // already present
|
|
Notes []string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// HasLegacyData reports whether root holds an importable legacy store: a
|
|
// config.yaml with at least one project. Used for the first-boot opt-in check.
|
|
func HasLegacyData(root string) bool {
|
|
if root == "" {
|
|
return false
|
|
}
|
|
cfg, err := loadLegacyConfig(root)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return len(cfg.Projects) > 0
|
|
}
|
|
|
|
// rewriteProjectID gates the rewrite project-id charset (validateProjectID,
|
|
// service.go). Legacy ids are a strict subset, so this all but always passes;
|
|
// it guards against a hand-edited legacy config carrying an illegal id.
|
|
var rewriteProjectID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
|
|
|
func isValidRewriteProjectID(id string) bool {
|
|
return id != "" && id != "." && !strings.Contains(id, "..") &&
|
|
!strings.ContainsAny(id, `/\`) && rewriteProjectID.MatchString(id)
|
|
}
|
|
|
|
// Run reads the legacy store and writes projects into store. It never modifies
|
|
// legacy files. It is idempotent: existing rows are skipped. A per-project
|
|
// parse or write failure is recorded as a note and does not abort the whole
|
|
// run, except a store write error, which is returned.
|
|
func Run(ctx context.Context, store Store, opts Options) (Report, error) {
|
|
root := opts.Root
|
|
if root == "" {
|
|
root = DefaultLegacyRootDir()
|
|
}
|
|
now := opts.Now
|
|
if now.IsZero() {
|
|
now = time.Now().UTC()
|
|
}
|
|
resolveOrigin := opts.RepoOriginURL
|
|
if resolveOrigin == nil {
|
|
resolveOrigin = defaultRepoOriginURL
|
|
}
|
|
|
|
rep := Report{DryRun: opts.DryRun}
|
|
|
|
cfg, err := loadLegacyConfig(root)
|
|
if err != nil {
|
|
return rep, err
|
|
}
|
|
if len(cfg.Projects) == 0 {
|
|
rep.Notes = append(rep.Notes, "no legacy projects found at "+root)
|
|
return rep, nil
|
|
}
|
|
|
|
configMtime := ""
|
|
if info, err := os.Stat(globalConfigPath(root)); err == nil {
|
|
configMtime = info.ModTime().UTC().Format(time.RFC3339)
|
|
}
|
|
prefs := loadPreferences(root)
|
|
reg := loadRegistered(root)
|
|
|
|
// Deterministic order: ids sorted.
|
|
ids := make([]string, 0, len(cfg.Projects))
|
|
for id := range cfg.Projects {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
|
|
deps := projectRowDeps{repoOriginURL: resolveOrigin, configMtime: configMtime, now: now}
|
|
|
|
for _, id := range ids {
|
|
pc := cfg.Projects[id]
|
|
if !isValidRewriteProjectID(id) {
|
|
rep.Notes = append(rep.Notes, "project "+quote(id)+" skipped: id is not a valid rewrite project id")
|
|
continue
|
|
}
|
|
|
|
record, notes := buildProjectRecord(id, pc, prefs, reg, deps)
|
|
rep.Notes = appendPrefixed(rep.Notes, id, notes)
|
|
|
|
if err := importProject(ctx, store, record, opts.DryRun, &rep); err != nil {
|
|
return rep, err
|
|
}
|
|
}
|
|
return rep, nil
|
|
}
|
|
|
|
func importProject(ctx context.Context, store Store, record domain.ProjectRecord, dryRun bool, rep *Report) error {
|
|
_, exists, err := store.GetProject(ctx, record.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("lookup project %s: %w", record.ID, err)
|
|
}
|
|
if exists {
|
|
rep.ProjectsSkipped++
|
|
return nil
|
|
}
|
|
if dryRun {
|
|
rep.ProjectsImported++
|
|
return nil
|
|
}
|
|
if err := store.UpsertProject(ctx, record); err != nil {
|
|
return fmt.Errorf("write project %s: %w", record.ID, err)
|
|
}
|
|
rep.ProjectsImported++
|
|
return nil
|
|
}
|
|
|
|
func appendPrefixed(dst []string, id string, notes []string) []string {
|
|
for _, n := range notes {
|
|
dst = append(dst, id+": "+n)
|
|
}
|
|
return dst
|
|
}
|
|
|
|
// quote wraps s in double quotes for note messages, rendering an empty string as
|
|
// "?" so a missing value is still legible.
|
|
func quote(s string) string {
|
|
if s == "" {
|
|
return `"?"`
|
|
}
|
|
return `"` + s + `"`
|
|
}
|
|
|
|
// defaultRepoOriginURL resolves a repo's git origin URL, "" when the repo is
|
|
// absent or has no origin. Matches the rewrite's resolveGitOriginURL.
|
|
func defaultRepoOriginURL(path string) string {
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
cmd := aoprocess.Command("git", "-C", path, "remote", "get-url", "origin")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|