feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// ProjectCoverage reports how well a project registered in registry.db is
|
||||
// backed by a Gitea sub-repo and how many of its children (apps + analyses)
|
||||
// are actually cloned on disk. It is the engine of `fn doctor projects`.
|
||||
//
|
||||
// The audit only touches the local filesystem and registry.db: it never hits
|
||||
// the network nor the Gitea API, so it runs fast and requires no token.
|
||||
type ProjectCoverage struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
DirPath string `json:"dir_path"`
|
||||
HasGit bool `json:"has_git"` // <root>/<dir_path>/.git exists as a directory
|
||||
HasRemote bool `json:"has_remote"` // git -C <dir> remote get-url origin returned a non-empty url
|
||||
RepoURLDeclared bool `json:"repo_url_declared"` // projects.repo_url != ""
|
||||
ChildrenInDB int `json:"children_in_db"` // apps + analyses with project_id = ProjectID
|
||||
ChildrenCloned int `json:"children_cloned"` // of those, how many have a .git on disk
|
||||
ChildrenMissing int `json:"children_missing"` // ChildrenInDB - ChildrenCloned (at risk when re-cloning)
|
||||
Issues []string `json:"issues"` // e.g. "dir_not_found", "no_gitea_repo", "children_missing"
|
||||
}
|
||||
|
||||
// AuditProjectsCoverage walks every row in the projects table and reports, per
|
||||
// project, whether it has a local git repo, whether that repo declares a remote
|
||||
// origin, whether its repo_url is filled in registry.db, and how many of its
|
||||
// children (apps + analyses) are cloned versus only known to the database.
|
||||
//
|
||||
// registryRoot is the repository root (the directory that holds registry.db).
|
||||
// All relative dir_path values are resolved against it.
|
||||
//
|
||||
// Returns an error only if registry.db cannot be opened or queried. Projects
|
||||
// whose directory is missing on disk are still reported, flagged with the
|
||||
// "dir_not_found" issue, so the caller can surface them rather than silently
|
||||
// dropping them.
|
||||
func AuditProjectsCoverage(registryRoot string) ([]ProjectCoverage, error) {
|
||||
dbPath := filepath.Join(registryRoot, "registry.db")
|
||||
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_projects_coverage: open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("audit_projects_coverage: ping db: %w", err)
|
||||
}
|
||||
|
||||
rows, err := db.Query(`SELECT id, COALESCE(dir_path,''), COALESCE(repo_url,'') FROM projects ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_projects_coverage: query projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ProjectCoverage
|
||||
for rows.Next() {
|
||||
var pc ProjectCoverage
|
||||
var dirPath, repoURL string
|
||||
if err := rows.Scan(&pc.ProjectID, &dirPath, &repoURL); err != nil {
|
||||
return nil, fmt.Errorf("audit_projects_coverage: scan project: %w", err)
|
||||
}
|
||||
|
||||
// DirPath: use projects.dir_path; if empty, derive projects/<id>.
|
||||
if dirPath == "" {
|
||||
dirPath = filepath.Join("projects", pc.ProjectID)
|
||||
}
|
||||
pc.DirPath = dirPath
|
||||
pc.RepoURLDeclared = repoURL != ""
|
||||
|
||||
absDir := dirPath
|
||||
if !filepath.IsAbs(absDir) {
|
||||
absDir = filepath.Join(registryRoot, dirPath)
|
||||
}
|
||||
|
||||
dirFound := dirExists(absDir)
|
||||
if !dirFound {
|
||||
pc.Issues = append(pc.Issues, "dir_not_found")
|
||||
} else {
|
||||
pc.HasGit = dirExists(filepath.Join(absDir, ".git"))
|
||||
if pc.HasGit {
|
||||
pc.HasRemote = gitHasRemoteOrigin(absDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Children: apps + analyses with this project_id.
|
||||
children, err := projectChildren(db, pc.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pc.ChildrenInDB = len(children)
|
||||
for _, childDir := range children {
|
||||
absChild := childDir
|
||||
if !filepath.IsAbs(absChild) {
|
||||
absChild = filepath.Join(registryRoot, childDir)
|
||||
}
|
||||
if dirExists(filepath.Join(absChild, ".git")) {
|
||||
pc.ChildrenCloned++
|
||||
}
|
||||
}
|
||||
pc.ChildrenMissing = pc.ChildrenInDB - pc.ChildrenCloned
|
||||
|
||||
// Issues derived from the gathered state.
|
||||
if !pc.HasRemote && !pc.RepoURLDeclared {
|
||||
pc.Issues = append(pc.Issues, "no_gitea_repo")
|
||||
}
|
||||
if pc.ChildrenMissing > 0 {
|
||||
pc.Issues = append(pc.Issues, "children_missing")
|
||||
}
|
||||
|
||||
out = append(out, pc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// projectChildren returns the dir_path of every app and analysis whose
|
||||
// project_id matches the given project id.
|
||||
func projectChildren(db *sql.DB, projectID string) ([]string, error) {
|
||||
var dirs []string
|
||||
for _, table := range []string{"apps", "analysis"} {
|
||||
q := fmt.Sprintf(`SELECT COALESCE(dir_path,'') FROM %s WHERE project_id = ?`, table)
|
||||
rows, err := db.Query(q, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_projects_coverage: query %s children: %w", table, err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var dp string
|
||||
if err := rows.Scan(&dp); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("audit_projects_coverage: scan %s child: %w", table, err)
|
||||
}
|
||||
if dp != "" {
|
||||
dirs = append(dirs, dp)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
// gitHasRemoteOrigin reports whether the git repo at dir declares an origin
|
||||
// remote with a non-empty URL. It shells out to `git -C <dir> remote get-url
|
||||
// origin` and treats exit 0 with non-empty output as success. Any error
|
||||
// (no origin, not a repo, git missing) is reported as false.
|
||||
func gitHasRemoteOrigin(dir string) bool {
|
||||
cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin")
|
||||
outBytes, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(outBytes)) != ""
|
||||
}
|
||||
|
||||
// FormatProjectsCoverage renders a tabwriter table, one row per project, with
|
||||
// git / remote / repo_url presence, children cloned vs declared, and the
|
||||
// issues list. When no project has coverage problems it makes that explicit.
|
||||
func FormatProjectsCoverage(rows []ProjectCoverage) string {
|
||||
var sb strings.Builder
|
||||
w := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "PROJECT\tGIT\tREMOTE\tREPO_URL\tCHILDREN\tISSUES")
|
||||
|
||||
withIssues := 0
|
||||
for _, r := range rows {
|
||||
issues := "-"
|
||||
if len(r.Issues) > 0 {
|
||||
issues = strings.Join(r.Issues, "; ")
|
||||
withIssues++
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d/%d\t%s\n",
|
||||
r.ProjectID,
|
||||
checkMark(r.HasGit),
|
||||
checkMark(r.HasRemote),
|
||||
checkMark(r.RepoURLDeclared),
|
||||
r.ChildrenCloned, r.ChildrenInDB,
|
||||
issues,
|
||||
)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
if len(rows) == 0 {
|
||||
sb.WriteString("\nNo projects registered.\n")
|
||||
return sb.String()
|
||||
}
|
||||
if withIssues == 0 {
|
||||
sb.WriteString("\n0 projects con problemas de cobertura.\n")
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "\n%d/%d projects con problemas de cobertura.\n", withIssues, len(rows))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// checkMark returns ✓ for true, ✗ for false.
|
||||
func checkMark(b bool) string {
|
||||
if b {
|
||||
return "✓"
|
||||
}
|
||||
return "✗"
|
||||
}
|
||||
|
||||
// OrphanProjectRef describes a project_id that one or more children (apps and
|
||||
// analyses) reference in registry.db but for which no row exists in the projects
|
||||
// table. This is the inverse drift of AuditProjectsCoverage: instead of a
|
||||
// registered project whose children are not cloned, it surfaces an umbrella
|
||||
// project that was never synced to this PC (or never created here at all), so
|
||||
// the children are pointing at a project that this registry does not know about.
|
||||
// That is a data-loss risk: the umbrella project may exist on another machine
|
||||
// and the link would be silently broken on a clone/sync into this one.
|
||||
type OrphanProjectRef struct {
|
||||
ProjectID string `json:"project_id"` // referenced by children but missing from projects
|
||||
Apps []string `json:"apps"` // ids of apps that reference it (sorted)
|
||||
Analyses []string `json:"analyses"` // ids of analyses that reference it (sorted)
|
||||
}
|
||||
|
||||
// FindOrphanProjectRefs scans every app and analysis that declares a non-empty
|
||||
// project_id and reports those project_id values that have no matching row in
|
||||
// the projects table. Each orphan groups the ids of the apps and analyses that
|
||||
// reference it.
|
||||
//
|
||||
// registryRoot is the repository root (the directory that holds registry.db).
|
||||
//
|
||||
// The result is sorted by ProjectID, and within each entry the Apps and
|
||||
// Analyses lists are sorted alphabetically. When every child references a known
|
||||
// project, an empty (non-nil) slice is returned with a nil error.
|
||||
//
|
||||
// Like AuditProjectsCoverage it only reads registry.db (opened read-only) and
|
||||
// never touches the network nor the Gitea API. Returns an error only when
|
||||
// registry.db cannot be opened or queried.
|
||||
func FindOrphanProjectRefs(registryRoot string) ([]OrphanProjectRef, error) {
|
||||
dbPath := filepath.Join(registryRoot, "registry.db")
|
||||
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: ping db: %w", err)
|
||||
}
|
||||
|
||||
// Set of known project ids.
|
||||
known := map[string]bool{}
|
||||
prows, err := db.Query(`SELECT id FROM projects`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: query projects: %w", err)
|
||||
}
|
||||
for prows.Next() {
|
||||
var id string
|
||||
if err := prows.Scan(&id); err != nil {
|
||||
prows.Close()
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: scan project: %w", err)
|
||||
}
|
||||
known[id] = true
|
||||
}
|
||||
if err := prows.Err(); err != nil {
|
||||
prows.Close()
|
||||
return nil, err
|
||||
}
|
||||
prows.Close()
|
||||
|
||||
// Accumulate orphan references from apps and analyses.
|
||||
orphans := map[string]*OrphanProjectRef{}
|
||||
|
||||
get := func(pid string) *OrphanProjectRef {
|
||||
o, ok := orphans[pid]
|
||||
if !ok {
|
||||
o = &OrphanProjectRef{ProjectID: pid}
|
||||
orphans[pid] = o
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
for _, table := range []string{"apps", "analysis"} {
|
||||
q := fmt.Sprintf(`SELECT id, project_id FROM %s WHERE project_id != ''`, table)
|
||||
rows, err := db.Query(q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: query %s: %w", table, err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, pid string
|
||||
if err := rows.Scan(&id, &pid); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("find_orphan_project_refs: scan %s row: %w", table, err)
|
||||
}
|
||||
if known[pid] {
|
||||
continue
|
||||
}
|
||||
o := get(pid)
|
||||
if table == "apps" {
|
||||
o.Apps = append(o.Apps, id)
|
||||
} else {
|
||||
o.Analyses = append(o.Analyses, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
out := make([]OrphanProjectRef, 0, len(orphans))
|
||||
for _, o := range orphans {
|
||||
sort.Strings(o.Apps)
|
||||
sort.Strings(o.Analyses)
|
||||
out = append(out, *o)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ProjectID < out[j].ProjectID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FormatOrphanProjectRefs renders a human-readable report of orphan project_id
|
||||
// references, one block per orphan, listing how many apps and analyses point at
|
||||
// the missing project plus their ids. When there are no orphans it makes that
|
||||
// explicit on a single line.
|
||||
func FormatOrphanProjectRefs(rows []OrphanProjectRef) string {
|
||||
if len(rows) == 0 {
|
||||
return "0 project_id huérfanos (todos los hijos tienen project declarado)\n"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
w := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "PROJECT_ID\tAPPS\tANALYSES\tREFERENCED_BY")
|
||||
for _, r := range rows {
|
||||
refs := append(append([]string{}, r.Apps...), r.Analyses...)
|
||||
refStr := strings.Join(refs, ", ")
|
||||
if refStr == "" {
|
||||
refStr = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%d\t%d\t%s\n",
|
||||
r.ProjectID, len(r.Apps), len(r.Analyses), refStr)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
fmt.Fprintf(&sb, "\n%d project_id huérfanos (referenciados por hijos pero sin fila en projects).\n", len(rows))
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: audit_projects_coverage
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func AuditProjectsCoverage(registryRoot string) ([]ProjectCoverage, error)"
|
||||
description: "Audita la cobertura de los projects del registry frente a sus sub-repos Gitea: comprueba si cada project tiene .git local, remote origin y repo_url declarado, y cuantos de sus hijos (apps + analyses) estan clonados en disco versus solo conocidos por la BD. Motor del subcomando fn doctor projects. Solo lee registry.db + filesystem + git local, nunca la red ni la API de Gitea. Incluye FindOrphanProjectRefs, el check inverso: detecta apps o analyses que declaran un project_id sin fila en la tabla projects (project paraguas huerfano, riesgo de perdida al sincronizar)."
|
||||
tags: [projects, gitea, subrepo, audit, infra, fn-doctor, doctor]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["database/sql", "os/exec", "path/filepath", "strings", "text/tabwriter", "github.com/mattn/go-sqlite3"]
|
||||
tested: true
|
||||
tests: ["healthy project con un hijo sin clonar marca children_missing", "project sin repo_url ni remote marca no_gitea_repo", "project sin directorio en disco marca dir_not_found", "error si registry.db no existe", "repo con origin devuelve true", "repo sin origin devuelve false", "sin issues lo deja claro", "con issues cuenta los afectados", "app con project_id huerfano lo detecta y agrupa ordenado", "app con project_id valido no aparece", "sin huerfanos devuelve slice vacio sin error", "sin huerfanos lo deja claro", "con huerfanos lista ids y cuenta"]
|
||||
test_file_path: "functions/infra/audit_projects_coverage_test.go"
|
||||
file_path: "functions/infra/audit_projects_coverage.go"
|
||||
params:
|
||||
- name: registryRoot
|
||||
desc: "Raiz del repositorio (el directorio que contiene registry.db). Los dir_path relativos de projects, apps y analysis se resuelven contra esta raiz."
|
||||
output: "Slice de ProjectCoverage, una entrada por fila de la tabla projects, con flags de git/remote/repo_url, conteos de hijos clonados vs declarados, y la lista de issues detectados. La funcion de formato FormatProjectsCoverage produce una tabla de texto humano."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
rows, err := infra.AuditProjectsCoverage("/home/enmanuel/fn_registry")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Print(infra.FormatProjectsCoverage(rows))
|
||||
}
|
||||
```
|
||||
|
||||
Salida típica:
|
||||
|
||||
```
|
||||
PROJECT GIT REMOTE REPO_URL CHILDREN ISSUES
|
||||
fleet_monitoring ✓ ✓ ✓ 2/2 -
|
||||
fn_monitoring ✓ ✓ ✓ 3/3 -
|
||||
message_bus ✓ ✓ ✓ 3/4 children_missing
|
||||
web_scraping ✗ ✗ ✗ 0/3 no_gitea_repo; children_missing
|
||||
|
||||
1/4 projects con problemas de cobertura.
|
||||
```
|
||||
|
||||
## Check inverso: FindOrphanProjectRefs
|
||||
|
||||
Mientras `AuditProjectsCoverage` parte de la tabla `projects` y mira hacia abajo (¿están sus hijos clonados?), `FindOrphanProjectRefs` recorre el grafo en sentido contrario: parte de las apps y analyses y mira hacia arriba (¿existe el project paraguas que declaran?). Detecta el drift inverso, apps o analyses cuyo `project_id` no tiene ninguna fila en la tabla `projects`. Es un project huérfano: existe en otro PC y nunca se sincronizó a este, o nunca se creó aquí. Es un riesgo de pérdida silenciosa, porque el enlace del hijo apunta a un project que este registro no conoce.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
orphans, err := infra.FindOrphanProjectRefs("/home/enmanuel/fn_registry")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Print(infra.FormatOrphanProjectRefs(orphans))
|
||||
}
|
||||
```
|
||||
|
||||
La firma es `func FindOrphanProjectRefs(registryRoot string) ([]OrphanProjectRef, error)`. Cada `OrphanProjectRef` agrupa, por `ProjectID` huérfano, los ids de las apps (`Apps`) y analyses (`Analyses`) que lo referencian, ambas listas ordenadas alfabéticamente y el slice resultante ordenado por `ProjectID`. Cuando todos los hijos apuntan a un project conocido devuelve un slice vacío (no nil) sin error. `FormatOrphanProjectRefs` produce una tabla de texto humano con el `project_id`, cuántas apps y analyses lo referencian y sus ids; si no hay huérfanos imprime una sola línea dejándolo claro.
|
||||
|
||||
Caso real detectado en este registro: apps con `project_id` ∈ {`element_agents`, `imagegen`, `osint_graph`} sin fila correspondiente en `projects`.
|
||||
|
||||
Salida típica con huérfanos:
|
||||
|
||||
```
|
||||
PROJECT_ID APPS ANALYSES REFERENCED_BY
|
||||
element_agents 1 0 shell_agent
|
||||
imagegen 1 0 imagegen_ui
|
||||
osint_graph 2 1 graph_explorer, scraper, gliner_glirel_tuning
|
||||
|
||||
3 project_id huérfanos (referenciados por hijos pero sin fila en projects).
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala antes de un `/full-git-pull` masivo o tras clonar el registry en un PC nuevo para saber qué projects están realmente respaldados por su sub-repo Gitea y cuántos de sus hijos (apps y analyses) quedarían sin clonar. También como motor del futuro subcomando `fn doctor projects`: el caller la enchufa desde `cmd/fn/doctor.go` igual que `AuditUsesFunctions` o `AuditServicesSpec`, formatea con `FormatProjectsCoverage` para texto humano y serializa el slice directamente para `--json`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Es **impura**: lee `registry.db` (abierto en modo read-only `?mode=ro`), recorre el filesystem y ejecuta `git -C <dir> remote get-url origin`. No toca la red ni la API de Gitea, así que no necesita token y es rápida.
|
||||
- `HasRemote` solo se evalúa cuando el project tiene `.git` local; si no hay `.git`, queda `false` sin intentar el comando git.
|
||||
- `gitHasRemoteOrigin` devuelve `false` ante cualquier error (no hay remote `origin`, no es un repo, git no instalado). No distingue "sin origin" de "git ausente"; si necesitas esa distinción, comprueba `git` por separado.
|
||||
- El issue `no_gitea_repo` se emite solo cuando faltan **ambos** indicadores (`!HasRemote && !RepoURLDeclared`). Un project con `repo_url` declarado pero sin clonar (`dir_not_found`) NO se marca `no_gitea_repo` — el repo existe en Gitea, simplemente no está en este disco.
|
||||
- `ChildrenMissing` cuenta los hijos (apps + analyses con ese `project_id`) cuya carpeta no tiene `.git` en disco: son los que se perderían o habría que reclonar. Cero hijos en la BD produce `0/0` y no genera issue.
|
||||
- Si `projects.dir_path` está vacío, se deriva `projects/<id>`. Los `dir_path` ya absolutos se respetan tal cual.
|
||||
- Devuelve error únicamente si `registry.db` no puede abrirse o consultarse. Los projects cuyo directorio no existe SÍ aparecen en el resultado, marcados con `dir_not_found`, para que el caller los muestre en vez de descartarlos en silencio.
|
||||
- `FindOrphanProjectRefs` también es **impura**: lee `registry.db` en modo read-only (`?mode=ro`), pero no toca el filesystem ni git, solo cruza las tablas `projects`, `apps` y `analysis`. Ignora los hijos con `project_id` vacío (no son huérfanos, simplemente no pertenecen a ningún project). Devuelve un slice vacío no-nil cuando no hay huérfanos, así que el caller puede distinguir "sin huérfanos" (slice vacío, error nil) de "fallo al leer la BD" (error no nil).
|
||||
@@ -0,0 +1,417 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// seedProjectsRegistry builds a temp registry.db with the columns
|
||||
// AuditProjectsCoverage reads, plus a handful of on-disk directories that
|
||||
// model the cloned/missing states.
|
||||
func seedProjectsRegistry(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "registry.db")
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open temp db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
repo_url TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
project_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE analysis (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
project_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO projects VALUES
|
||||
('healthy', 'projects/healthy', 'https://gitea.example/dataforge/healthy'),
|
||||
('no_repo', 'projects/no_repo', ''),
|
||||
('missing', 'projects/missing', 'https://gitea.example/dataforge/missing');
|
||||
INSERT INTO apps VALUES
|
||||
('app_cloned', 'projects/healthy/apps/app_cloned', 'healthy'),
|
||||
('app_orphan', 'projects/healthy/apps/app_orphan', 'healthy');
|
||||
INSERT INTO analysis VALUES
|
||||
('an_cloned', 'projects/healthy/analysis/an_cloned', 'healthy');
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed data: %v", err)
|
||||
}
|
||||
|
||||
// healthy: directory + .git present; one child cloned, one missing.
|
||||
mkGitDir(t, dir, "projects/healthy")
|
||||
mkGitDir(t, dir, "projects/healthy/apps/app_cloned")
|
||||
mkGitDir(t, dir, "projects/healthy/analysis/an_cloned")
|
||||
// app_orphan has no .git on disk → counts as missing.
|
||||
|
||||
// no_repo: directory exists, no .git.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "projects/no_repo"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir no_repo: %v", err)
|
||||
}
|
||||
|
||||
// missing: no directory at all on disk.
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// mkGitDir creates <root>/<rel>/.git so dirExists treats it as a git repo.
|
||||
func mkGitDir(t *testing.T, root, rel string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(root, rel, ".git"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s/.git: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func findCoverage(rows []ProjectCoverage, id string) *ProjectCoverage {
|
||||
for i := range rows {
|
||||
if rows[i].ProjectID == id {
|
||||
return &rows[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasIssue(pc *ProjectCoverage, issue string) bool {
|
||||
for _, i := range pc.Issues {
|
||||
if i == issue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAuditProjectsCoverage(t *testing.T) {
|
||||
t.Run("healthy project con un hijo sin clonar marca children_missing", func(t *testing.T) {
|
||||
dir := seedProjectsRegistry(t)
|
||||
rows, err := AuditProjectsCoverage(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("AuditProjectsCoverage error: %v", err)
|
||||
}
|
||||
pc := findCoverage(rows, "healthy")
|
||||
if pc == nil {
|
||||
t.Fatal("project 'healthy' missing from results")
|
||||
}
|
||||
if !pc.HasGit {
|
||||
t.Error("expected HasGit=true for healthy")
|
||||
}
|
||||
if !pc.RepoURLDeclared {
|
||||
t.Error("expected RepoURLDeclared=true for healthy")
|
||||
}
|
||||
if pc.ChildrenInDB != 3 {
|
||||
t.Errorf("expected 3 children in db, got %d", pc.ChildrenInDB)
|
||||
}
|
||||
if pc.ChildrenCloned != 2 {
|
||||
t.Errorf("expected 2 cloned children, got %d", pc.ChildrenCloned)
|
||||
}
|
||||
if pc.ChildrenMissing != 1 {
|
||||
t.Errorf("expected 1 missing child, got %d", pc.ChildrenMissing)
|
||||
}
|
||||
if !hasIssue(pc, "children_missing") {
|
||||
t.Errorf("expected children_missing issue, got %v", pc.Issues)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("project sin repo_url ni remote marca no_gitea_repo", func(t *testing.T) {
|
||||
dir := seedProjectsRegistry(t)
|
||||
rows, err := AuditProjectsCoverage(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
pc := findCoverage(rows, "no_repo")
|
||||
if pc == nil {
|
||||
t.Fatal("project 'no_repo' missing")
|
||||
}
|
||||
if pc.HasGit {
|
||||
t.Error("expected HasGit=false for no_repo")
|
||||
}
|
||||
if pc.RepoURLDeclared {
|
||||
t.Error("expected RepoURLDeclared=false for no_repo")
|
||||
}
|
||||
if !hasIssue(pc, "no_gitea_repo") {
|
||||
t.Errorf("expected no_gitea_repo issue, got %v", pc.Issues)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("project sin directorio en disco marca dir_not_found", func(t *testing.T) {
|
||||
dir := seedProjectsRegistry(t)
|
||||
rows, err := AuditProjectsCoverage(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
pc := findCoverage(rows, "missing")
|
||||
if pc == nil {
|
||||
t.Fatal("project 'missing' missing")
|
||||
}
|
||||
if !hasIssue(pc, "dir_not_found") {
|
||||
t.Errorf("expected dir_not_found issue, got %v", pc.Issues)
|
||||
}
|
||||
if pc.HasGit {
|
||||
t.Error("expected HasGit=false when dir not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si registry.db no existe", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := AuditProjectsCoverage(dir); err == nil {
|
||||
t.Error("expected error for missing db, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitHasRemoteOrigin(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
t.Run("repo con origin devuelve true", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runGit(t, dir, "init", "-q")
|
||||
runGit(t, dir, "remote", "add", "origin", "https://example.com/x.git")
|
||||
if !gitHasRemoteOrigin(dir) {
|
||||
t.Error("expected true for repo with origin")
|
||||
}
|
||||
})
|
||||
t.Run("repo sin origin devuelve false", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runGit(t, dir, "init", "-q")
|
||||
if gitHasRemoteOrigin(dir) {
|
||||
t.Error("expected false for repo without origin")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// seedOrphanRefsRegistry builds a temp registry.db where some children declare
|
||||
// a project_id with no matching row in the projects table (orphan refs) while
|
||||
// others reference a valid project. It returns the registry root directory.
|
||||
func seedOrphanRefsRegistry(t *testing.T, withOrphans bool) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "registry.db")
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open temp db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
repo_url TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
project_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE analysis (
|
||||
id TEXT PRIMARY KEY,
|
||||
dir_path TEXT NOT NULL DEFAULT '',
|
||||
project_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
|
||||
// One known project exists in the projects table.
|
||||
if _, err := db.Exec(`INSERT INTO projects VALUES ('known', 'projects/known', '')`); err != nil {
|
||||
t.Fatalf("seed projects: %v", err)
|
||||
}
|
||||
|
||||
// app_valid + an_valid reference the known project: never orphans.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO apps VALUES ('app_valid', 'projects/known/apps/app_valid', 'known');
|
||||
INSERT INTO analysis VALUES ('an_valid', 'projects/known/analysis/an_valid', 'known');
|
||||
`); err != nil {
|
||||
t.Fatalf("seed valid children: %v", err)
|
||||
}
|
||||
|
||||
// app_no_project has an empty project_id: must be ignored.
|
||||
if _, err := db.Exec(`INSERT INTO apps VALUES ('app_no_project', 'apps/app_no_project', '')`); err != nil {
|
||||
t.Fatalf("seed no-project app: %v", err)
|
||||
}
|
||||
|
||||
if withOrphans {
|
||||
// Two apps + one analysis pointing at 'ghost' (missing from projects),
|
||||
// plus one app pointing at 'specter' (also missing). Insert the apps for
|
||||
// 'ghost' out of alphabetical order to exercise sorting.
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO apps VALUES ('app_zeta', 'apps/app_zeta', 'ghost');
|
||||
INSERT INTO apps VALUES ('app_alpha', 'apps/app_alpha', 'ghost');
|
||||
INSERT INTO analysis VALUES ('an_ghost', 'analysis/an_ghost', 'ghost');
|
||||
INSERT INTO apps VALUES ('app_specter', 'apps/app_specter', 'specter');
|
||||
`); err != nil {
|
||||
t.Fatalf("seed orphan children: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func findOrphan(rows []OrphanProjectRef, id string) *OrphanProjectRef {
|
||||
for i := range rows {
|
||||
if rows[i].ProjectID == id {
|
||||
return &rows[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFindOrphanProjectRefs(t *testing.T) {
|
||||
t.Run("app con project_id huerfano lo detecta y agrupa ordenado", func(t *testing.T) {
|
||||
dir := seedOrphanRefsRegistry(t, true)
|
||||
rows, err := FindOrphanProjectRefs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("FindOrphanProjectRefs error: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 orphan refs, got %d: %+v", len(rows), rows)
|
||||
}
|
||||
// Sorted by ProjectID: ghost before specter.
|
||||
if rows[0].ProjectID != "ghost" || rows[1].ProjectID != "specter" {
|
||||
t.Fatalf("expected [ghost specter], got [%s %s]", rows[0].ProjectID, rows[1].ProjectID)
|
||||
}
|
||||
|
||||
ghost := findOrphan(rows, "ghost")
|
||||
if ghost == nil {
|
||||
t.Fatal("orphan 'ghost' missing")
|
||||
}
|
||||
wantApps := []string{"app_alpha", "app_zeta"} // alphabetical
|
||||
if len(ghost.Apps) != 2 || ghost.Apps[0] != wantApps[0] || ghost.Apps[1] != wantApps[1] {
|
||||
t.Errorf("expected ghost.Apps=%v, got %v", wantApps, ghost.Apps)
|
||||
}
|
||||
if len(ghost.Analyses) != 1 || ghost.Analyses[0] != "an_ghost" {
|
||||
t.Errorf("expected ghost.Analyses=[an_ghost], got %v", ghost.Analyses)
|
||||
}
|
||||
|
||||
specter := findOrphan(rows, "specter")
|
||||
if specter == nil {
|
||||
t.Fatal("orphan 'specter' missing")
|
||||
}
|
||||
if len(specter.Apps) != 1 || specter.Apps[0] != "app_specter" {
|
||||
t.Errorf("expected specter.Apps=[app_specter], got %v", specter.Apps)
|
||||
}
|
||||
if len(specter.Analyses) != 0 {
|
||||
t.Errorf("expected specter.Analyses empty, got %v", specter.Analyses)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("app con project_id valido no aparece", func(t *testing.T) {
|
||||
dir := seedOrphanRefsRegistry(t, true)
|
||||
rows, err := FindOrphanProjectRefs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
if findOrphan(rows, "known") != nil {
|
||||
t.Error("valid project 'known' should not appear as orphan")
|
||||
}
|
||||
// Children of 'known' must never be listed in any orphan entry.
|
||||
for _, r := range rows {
|
||||
for _, a := range append(append([]string{}, r.Apps...), r.Analyses...) {
|
||||
if a == "app_valid" || a == "an_valid" || a == "app_no_project" {
|
||||
t.Errorf("child %q with valid/empty project leaked into orphan %q", a, r.ProjectID)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sin huerfanos devuelve slice vacio sin error", func(t *testing.T) {
|
||||
dir := seedOrphanRefsRegistry(t, false)
|
||||
rows, err := FindOrphanProjectRefs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
if rows == nil {
|
||||
t.Fatal("expected non-nil empty slice, got nil")
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("expected 0 orphans, got %d: %+v", len(rows), rows)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error si registry.db no existe", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := FindOrphanProjectRefs(dir); err == nil {
|
||||
t.Error("expected error for missing db, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatOrphanProjectRefs(t *testing.T) {
|
||||
t.Run("sin huerfanos lo deja claro", func(t *testing.T) {
|
||||
out := FormatOrphanProjectRefs(nil)
|
||||
if !contains(out, "0 project_id huérfanos") {
|
||||
t.Errorf("expected clean message, got:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("con huerfanos lista ids y cuenta", func(t *testing.T) {
|
||||
rows := []OrphanProjectRef{
|
||||
{ProjectID: "ghost", Apps: []string{"app_alpha", "app_zeta"}, Analyses: []string{"an_ghost"}},
|
||||
}
|
||||
out := FormatOrphanProjectRefs(rows)
|
||||
if !contains(out, "ghost") {
|
||||
t.Errorf("expected project_id in output, got:\n%s", out)
|
||||
}
|
||||
if !contains(out, "app_alpha") || !contains(out, "an_ghost") {
|
||||
t.Errorf("expected referencing ids in output, got:\n%s", out)
|
||||
}
|
||||
if !contains(out, "1 project_id huérfanos") {
|
||||
t.Errorf("expected count line, got:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatProjectsCoverage(t *testing.T) {
|
||||
t.Run("sin issues lo deja claro", func(t *testing.T) {
|
||||
rows := []ProjectCoverage{
|
||||
{ProjectID: "ok", HasGit: true, HasRemote: true, RepoURLDeclared: true, ChildrenInDB: 2, ChildrenCloned: 2},
|
||||
}
|
||||
out := FormatProjectsCoverage(rows)
|
||||
if !contains(out, "0 projects con problemas de cobertura") {
|
||||
t.Errorf("expected clean message, got:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("con issues cuenta los afectados", func(t *testing.T) {
|
||||
rows := []ProjectCoverage{
|
||||
{ProjectID: "bad", Issues: []string{"no_gitea_repo"}},
|
||||
{ProjectID: "ok", HasGit: true, HasRemote: true, RepoURLDeclared: true},
|
||||
}
|
||||
out := FormatProjectsCoverage(rows)
|
||||
if !contains(out, "1/2 projects con problemas de cobertura") {
|
||||
t.Errorf("expected 1/2 count, got:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user