diff --git a/.gitignore b/.gitignore index 095d205..e41c73d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,6 @@ go.work.sum /unmigrate /forkfix /migration_state.json +/bin log.log err.log \ No newline at end of file diff --git a/cmd/orgfix/main.go b/cmd/orgfix/main.go new file mode 100644 index 0000000..92a4595 --- /dev/null +++ b/cmd/orgfix/main.go @@ -0,0 +1,410 @@ +package main + +import ( + "database/sql" + "flag" + "fmt" + "log" + "os" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// Repository represents a Gitea repository record +type Repository struct { + ID int64 + Name string + OwnerID int64 + OwnerName string + IsFork bool + ForkID sql.NullInt64 +} + +// User represents a Gitea user or organization +type User struct { + ID int64 + Name string + Type int // 1 for individual, 2 for organization +} + +const ( + UserTypeIndividual = 0 + UserTypeOrganization = 1 +) + +func main() { + // Define command line flags + dbPath := flag.String("db", "", "Path to gitea.db (required)") + orgName := flag.String("org", "", "Name of the organization to match against (required)") + dryRun := flag.Bool("dry-run", false, "Perform a dry run without making changes") + backup := flag.Bool("backup", true, "Create a backup of the database before making changes") + verbose := flag.Bool("verbose", false, "Show detailed output") + help := flag.Bool("help", false, "Show help") + listOrgs := flag.Bool("list-orgs", false, "List all organizations in the database") + + flag.Parse() + + if *listOrgs { + if *dbPath == "" { + log.Fatal("Database path (-db) is required") + } + db, err := sql.Open("sqlite3", *dbPath) + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Verify database connection + if err := db.Ping(); err != nil { + log.Fatalf("Failed to connect to database (possibly locked or invalid): %v", err) + } + + listOrganizations(db) + return + } + + // Display help if requested or if no arguments provided + if *help || flag.NFlag() == 0 { + printUsage() + return + } + + // Validate required arguments + if *dbPath == "" { + log.Fatal("Database path (-db) is required") + } + + if *orgName == "" { + log.Fatal("Organization name (-org) is required") + } + + // Create backup if requested and not in dry run mode + if *backup && !*dryRun { + backupFile := backupDatabase(*dbPath) + fmt.Printf("Created backup at: %s\n", backupFile) + } + + // Open database connection + db, err := sql.Open("sqlite3", *dbPath) + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Verify the database connection + if err := db.Ping(); err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + + // Get organization ID + orgID, err := getOrganizationID(db, *orgName) + if err != nil { + log.Fatalf("Failed to find organization '%s': %v", *orgName, err) + } + + // Get repositories owned by the organization + orgRepos, err := getOrganizationRepos(db, orgID) + if err != nil { + log.Fatalf("Failed to get organization repositories: %v", err) + } + + if len(orgRepos) == 0 { + fmt.Printf("No repositories found for organization '%s'\n", *orgName) + return + } + + fmt.Printf("Found %d repositories in organization '%s'\n", len(orgRepos), *orgName) + + // Build a map of organization repositories by name for quick lookup + orgRepoMap := make(map[string]*Repository) + for i, repo := range orgRepos { + orgRepoMap[repo.Name] = &orgRepos[i] + if *verbose { + fmt.Printf("Org repo: %s (ID: %d)\n", repo.Name, repo.ID) + } + } + + // Get all user repositories + userRepos, err := getUserRepos(db) + if err != nil { + log.Fatalf("Failed to get user repositories: %v", err) + } + + fmt.Printf("Found %d repositories in user namespaces\n", len(userRepos)) + + // Find matches and setup fork relationships + matchCount := 0 + updateCount := 0 + + for _, userRepo := range userRepos { + // Skip if the repo is already a fork + if userRepo.IsFork && userRepo.ForkID.Valid { + continue + } + + // Check if there's a matching org repo with the same name + if orgRepo, exists := orgRepoMap[userRepo.Name]; exists { + matchCount++ + + if *verbose || *dryRun { + fmt.Printf("Match found: %s/%s (ID: %d) -> %s/%s (ID: %d)\n", + userRepo.OwnerName, userRepo.Name, userRepo.ID, + *orgName, orgRepo.Name, orgRepo.ID) + } + + // Update the fork relationship if not in dry run mode + if !*dryRun { + err := updateForkRelationship(db, userRepo.ID, orgRepo.ID) + if err != nil { + log.Printf("Error updating fork relationship for %s/%s: %v", + userRepo.OwnerName, userRepo.Name, err) + } else { + updateCount++ + } + } + } + } + + fmt.Printf("\nSummary:\n") + fmt.Printf("- Matching repositories found: %d\n", matchCount) + + if *dryRun { + fmt.Printf("- Dry run mode: no changes made\n") + } else { + fmt.Printf("- Fork relationships updated: %d\n", updateCount) + } + + if !*dryRun { + fmt.Printf("\nNOTE: You should restart your Gitea instance for changes to take effect.\n") + } +} + +// printUsage displays the help information +func printUsage() { + fmt.Println("Gitea Organization Fork Matcher") + fmt.Println("\nThis tool identifies repositories with the same name in both organization and user") + fmt.Println("namespaces, then establishes proper fork relationships in Gitea's SQLite database.") + fmt.Println("\nUsage:") + fmt.Println(" gitea-org-fork-matcher -db /path/to/gitea.db -org YourOrgName") + fmt.Println("\nOptions:") + flag.PrintDefaults() + fmt.Println("\nNOTE: Always restart Gitea after making changes for them to take effect.") +} + +// backupDatabase creates a backup of the database file +func backupDatabase(dbPath string) string { + timestamp := time.Now().Format("20060102-150405") + backupPath := dbPath + ".backup-" + timestamp + + // Read the original database file + data, err := os.ReadFile(dbPath) + if err != nil { + log.Fatalf("Failed to read database for backup: %v", err) + } + + // Write to the backup file + err = os.WriteFile(backupPath, data, 0o644) + if err != nil { + log.Fatalf("Failed to create backup: %v", err) + } + + return backupPath +} + +// getOrganizationID retrieves the ID of an organization by name +func getOrganizationID(db *sql.DB, orgName string) (int64, error) { + var id int64 + query := `SELECT id FROM user WHERE name = ? AND type = ?` + err := db.QueryRow(query, orgName, UserTypeOrganization).Scan(&id) + if err != nil { + if err == sql.ErrNoRows { + return 0, fmt.Errorf("organization '%s' not found", orgName) + } + return 0, err + } + return id, nil +} + +// getOrganizationRepos retrieves all repositories owned by the given organization +func getOrganizationRepos(db *sql.DB, orgID int64) ([]Repository, error) { + query := ` + SELECT r.id, r.name, r.owner_id, u.name AS owner_name, r.is_fork, r.fork_id + FROM repository r + JOIN user u ON r.owner_id = u.id + WHERE r.owner_id = ? + ORDER BY r.name + ` + + rows, err := db.Query(query, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + var repos []Repository + for rows.Next() { + var repo Repository + if err := rows.Scan(&repo.ID, &repo.Name, &repo.OwnerID, &repo.OwnerName, &repo.IsFork, &repo.ForkID); err != nil { + return nil, err + } + repos = append(repos, repo) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return repos, nil +} + +// getUserRepos retrieves all repositories owned by individual users (not organizations) +func getUserRepos(db *sql.DB) ([]Repository, error) { + query := ` + SELECT r.id, r.name, r.owner_id, u.name AS owner_name, r.is_fork, r.fork_id + FROM repository r + JOIN user u ON r.owner_id = u.id + WHERE u.type = ? + ORDER BY r.name, u.name + ` + + rows, err := db.Query(query, UserTypeIndividual) + if err != nil { + return nil, err + } + defer rows.Close() + + var repos []Repository + for rows.Next() { + var repo Repository + if err := rows.Scan(&repo.ID, &repo.Name, &repo.OwnerID, &repo.OwnerName, &repo.IsFork, &repo.ForkID); err != nil { + return nil, err + } + repos = append(repos, repo) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return repos, nil +} + +// updateForkRelationship sets a repository as a fork of another +func updateForkRelationship(db *sql.DB, forkID, originalID int64) error { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %v", err) + } + + _, err = tx.Exec("UPDATE repository SET fork_id = ?, is_fork = 1 WHERE id = ?", originalID, forkID) + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to update fork relationship: %v", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit changes: %v", err) + } + + return nil +} + +// Enhanced organization listing function +func listOrganizations(db *sql.DB) { + // First, let's check if we can access the user table at all + var tableExists int + err := db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='user'").Scan(&tableExists) + if err != nil { + log.Fatalf("Error checking for user table: %v", err) + } + + if tableExists == 0 { + log.Fatalf("The 'user' table does not exist in this database") + } + + // Let's examine the schema of the user table + fmt.Println("User table schema:") + rows, err := db.Query("PRAGMA table_info(user)") + if err != nil { + log.Fatalf("Failed to query user table schema: %v", err) + } + + columns := make(map[string]string) + fmt.Println("Column\tType") + fmt.Println("------\t----") + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dfltValue interface{} + + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil { + log.Fatalf("Failed to scan schema row: %v", err) + } + columns[name] = ctype + fmt.Printf("%s\t%s\n", name, ctype) + } + rows.Close() + + // Check if the type column exists + if _, hasType := columns["type"]; !hasType { + // Check if it might be called something else, like "user_type" + altTypeColumns := []string{"user_type", "kind", "organization_type"} + for _, colName := range altTypeColumns { + if _, has := columns[colName]; has { + fmt.Printf("\nFound potential type column: '%s'\n", colName) + } + } + log.Fatalf("The 'type' column does not exist in user table") + } + + // Let's see what type values actually exist in the database + fmt.Println("\nDistinct user types in database:") + typeRows, err := db.Query("SELECT DISTINCT type, COUNT(*) FROM user GROUP BY type") + if err != nil { + log.Fatalf("Failed to query user types: %v", err) + } + + fmt.Println("Type\tCount") + fmt.Println("----\t-----") + typeFound := false + for typeRows.Next() { + var userType, count int + if err := typeRows.Scan(&userType, &count); err != nil { + log.Fatalf("Failed to scan type row: %v", err) + } + typeFound = true + fmt.Printf("%d\t%d\n", userType, count) + } + typeRows.Close() + + if !typeFound { + fmt.Println("No user type data found - table might be empty or type field has NULL values") + } + + // Now try to get the list of organizations regardless of type + fmt.Println("\nAttempting to list all users (potential organizations):") + userRows, err := db.Query("SELECT id, name, type FROM user ORDER BY name LIMIT 20") + if err != nil { + log.Fatalf("Failed to query users: %v", err) + } + + fmt.Println("ID\tName\tType") + fmt.Println("--\t----\t----") + userFound := false + for userRows.Next() { + var id, userType int + var name string + if err := userRows.Scan(&id, &name, &userType); err != nil { + log.Fatalf("Failed to scan user row: %v", err) + } + userFound = true + fmt.Printf("%d\t%s\t%d\n", id, name, userType) + } + userRows.Close() + + if !userFound { + fmt.Println("No users found in the database") + } +}