eb8dbf66a1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
348 lines
12 KiB
Go
348 lines
12 KiB
Go
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()
|
|
}
|