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"` // //.git exists as a directory HasRemote bool `json:"has_remote"` // git -C 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/. 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 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() }