diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-07 23:25:10 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-07 23:25:10 +0300 |
| commit | 4526c8a171dbe40762c116e5b8a404f20131d2b1 (patch) | |
| tree | ea3d544cbad995dabb616f4b6136e6e24a097524 | |
| parent | 64095a2c8d5a3a72c55d7bd0737c5542a5aeee09 (diff) | |
feat: add comprehensive showcase generation with metadata and images
- Add --showcase flag to generate project showcases using Claude
- Extract repository metadata (languages, commits, LOC, dates, license)
- Support image extraction from README files (local and remote)
- Add caching with --force flag to regenerate
- Add exclude_from_showcase config option
- Add standalone showcase mode (--showcase without sync)
- Sort projects by recent activity (avg age of last 100 commits)
- Output in Gemini Gemtext template format (.gmi.tpl)
- Fix backup location fetching when --backup flag not set
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
| -rw-r--r-- | cmd/gitsyncer/main.go | 43 | ||||
| -rw-r--r-- | internal/cli/flags.go | 4 | ||||
| -rw-r--r-- | internal/cli/showcase_handler.go | 35 | ||||
| -rw-r--r-- | internal/cli/showcase_only_handler.go | 112 | ||||
| -rw-r--r-- | internal/config/config.go | 9 | ||||
| -rw-r--r-- | internal/showcase/images.go | 258 | ||||
| -rw-r--r-- | internal/showcase/metadata.go | 365 | ||||
| -rw-r--r-- | internal/showcase/showcase.go | 560 | ||||
| -rw-r--r-- | internal/sync/sync.go | 50 |
9 files changed, 1428 insertions, 8 deletions
diff --git a/cmd/gitsyncer/main.go b/cmd/gitsyncer/main.go index ddf00e3..9828776 100644 --- a/cmd/gitsyncer/main.go +++ b/cmd/gitsyncer/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "path/filepath" @@ -59,25 +60,61 @@ func main() { // Handle sync operation if flags.SyncRepo != "" { - os.Exit(cli.HandleSync(cfg, flags)) + exitCode := cli.HandleSync(cfg, flags) + if exitCode == 0 && flags.Showcase { + showcaseCode := cli.HandleShowcase(cfg, flags) + if showcaseCode != 0 { + os.Exit(showcaseCode) + } + } + os.Exit(exitCode) } // Handle sync all operation if flags.SyncAll { - os.Exit(cli.HandleSyncAll(cfg, flags)) + exitCode := cli.HandleSyncAll(cfg, flags) + if exitCode == 0 && flags.Showcase { + showcaseCode := cli.HandleShowcase(cfg, flags) + if showcaseCode != 0 { + os.Exit(showcaseCode) + } + } + os.Exit(exitCode) } // Handle sync Codeberg public repos if flags.SyncCodebergPublic { exitCode := cli.HandleSyncCodebergPublic(cfg, flags) if exitCode != 0 || !flags.SyncGitHubPublic { + if exitCode == 0 && flags.Showcase && !flags.SyncGitHubPublic { + showcaseCode := cli.HandleShowcase(cfg, flags) + if showcaseCode != 0 { + os.Exit(showcaseCode) + } + } os.Exit(exitCode) } } // Handle sync GitHub public repos if flags.SyncGitHubPublic { - os.Exit(cli.HandleSyncGitHubPublic(cfg, flags)) + exitCode := cli.HandleSyncGitHubPublic(cfg, flags) + + // Run showcase generation if requested and sync was successful + if exitCode == 0 && flags.Showcase { + showcaseCode := cli.HandleShowcase(cfg, flags) + if showcaseCode != 0 { + os.Exit(showcaseCode) + } + } + + os.Exit(exitCode) + } + + // Handle standalone showcase mode (no sync operations specified) + if flags.Showcase { + fmt.Println("Running showcase generation for all repositories (clone-only mode)...") + os.Exit(cli.HandleShowcaseOnly(cfg, flags)) } // Default: show usage diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 322ad5a..7640398 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -25,6 +25,8 @@ type Flags struct { Clean bool DeleteRepo string Backup bool + Showcase bool + Force bool } // ParseFlags parses command-line flags and returns the flags struct @@ -50,6 +52,8 @@ func ParseFlags() *Flags { flag.BoolVar(&f.Clean, "clean", false, "delete all repositories in work directory (with confirmation)") flag.StringVar(&f.DeleteRepo, "delete-repo", "", "delete specified repository from all configured organizations (with confirmation)") flag.BoolVar(&f.Backup, "backup", false, "enable syncing to backup locations") + flag.BoolVar(&f.Showcase, "showcase", false, "generate project showcase using Claude after syncing") + flag.BoolVar(&f.Force, "force", false, "force regeneration of cached data") flag.Parse() diff --git a/internal/cli/showcase_handler.go b/internal/cli/showcase_handler.go new file mode 100644 index 0000000..a3dfd26 --- /dev/null +++ b/internal/cli/showcase_handler.go @@ -0,0 +1,35 @@ +package cli + +import ( + "fmt" + "log" + + "codeberg.org/snonux/gitsyncer/internal/config" + "codeberg.org/snonux/gitsyncer/internal/showcase" +) + +// HandleShowcase handles the showcase generation after syncing +func HandleShowcase(cfg *config.Config, flags *Flags) int { + // Determine which repositories to process + var repoFilter []string + if flags.SyncRepo != "" { + // Only process the specific repository that was synced + repoFilter = []string{flags.SyncRepo} + fmt.Printf("\nGenerating showcase for %s...\n", flags.SyncRepo) + } else { + // Process all repositories for --sync-all or public sync operations + fmt.Println("\nGenerating project showcase for all repositories...") + } + + // Create showcase generator + generator := showcase.New(cfg, flags.WorkDir) + + // Generate showcase with optional filter + if err := generator.GenerateShowcase(repoFilter, flags.Force); err != nil { + log.Printf("ERROR: Failed to generate showcase: %v\n", err) + return 1 + } + + fmt.Println("Showcase generated successfully!") + return 0 +}
\ No newline at end of file diff --git a/internal/cli/showcase_only_handler.go b/internal/cli/showcase_only_handler.go new file mode 100644 index 0000000..ff2a82a --- /dev/null +++ b/internal/cli/showcase_only_handler.go @@ -0,0 +1,112 @@ +package cli + +import ( + "fmt" + "log" + + "codeberg.org/snonux/gitsyncer/internal/codeberg" + "codeberg.org/snonux/gitsyncer/internal/config" + "codeberg.org/snonux/gitsyncer/internal/github" + "codeberg.org/snonux/gitsyncer/internal/showcase" + "codeberg.org/snonux/gitsyncer/internal/sync" +) + +// HandleShowcaseOnly handles showcase generation without syncing +// It will clone repositories if they don't exist locally, but won't sync changes +func HandleShowcaseOnly(cfg *config.Config, flags *Flags) int { + // Get all repositories from all sources + allRepos, err := getAllRepositories(cfg) + if err != nil { + log.Printf("ERROR: Failed to get repositories: %v\n", err) + return 1 + } + + if len(allRepos) == 0 { + fmt.Println("No repositories found") + return 1 + } + + fmt.Printf("Found %d repositories total\n", len(allRepos)) + + // Create a minimal syncer just for cloning + syncer := sync.New(cfg, flags.WorkDir) + syncer.SetBackupEnabled(false) // Never use backup in showcase-only mode + + // Ensure repositories are cloned (but not synced) + fmt.Println("\nEnsuring repositories are cloned locally...") + for _, repo := range allRepos { + if err := syncer.EnsureRepositoryCloned(repo); err != nil { + fmt.Printf("WARNING: Failed to clone %s: %v\n", repo, err) + // Continue with other repos + } + } + + // Generate showcase for all repositories + fmt.Println("\nGenerating showcase for all repositories...") + generator := showcase.New(cfg, flags.WorkDir) + + // Pass empty filter to process all repos + if err := generator.GenerateShowcase(nil, flags.Force); err != nil { + log.Printf("ERROR: Failed to generate showcase: %v\n", err) + return 1 + } + + fmt.Println("Showcase generation completed!") + return 0 +} + +// getAllRepositories collects all unique repository names from all sources +func getAllRepositories(cfg *config.Config) ([]string, error) { + repoMap := make(map[string]bool) + + // Add configured repositories + for _, repo := range cfg.Repositories { + repoMap[repo] = true + } + + // Add Codeberg public repos if configured + if codebergOrg := cfg.FindCodebergOrg(); codebergOrg != nil { + fmt.Printf("Fetching public repositories from Codeberg user/org: %s...\n", codebergOrg.Name) + client := codeberg.NewClient(codebergOrg.Name, codebergOrg.CodebergToken) + + repos, err := client.ListPublicRepos() + if err != nil { + // Try as user + repos, err = client.ListUserPublicRepos() + if err != nil { + fmt.Printf("Warning: Failed to fetch Codeberg repos: %v\n", err) + } + } + + for _, repo := range repos { + repoMap[repo.Name] = true + } + } + + // Add GitHub public repos if configured + if githubOrg := cfg.FindGitHubOrg(); githubOrg != nil { + fmt.Printf("Fetching public repositories from GitHub user/org: %s...\n", githubOrg.Name) + client := github.NewClient(githubOrg.GitHubToken, githubOrg.Name) + + if client.HasToken() { + repos, err := client.ListPublicRepos() + if err != nil { + fmt.Printf("Warning: Failed to fetch GitHub repos: %v\n", err) + } else { + for _, repo := range repos { + repoMap[repo.Name] = true + } + } + } else { + fmt.Println("Warning: No GitHub token found, skipping GitHub repos") + } + } + + // Convert map to slice + var allRepos []string + for repo := range repoMap { + allRepos = append(allRepos, repo) + } + + return allRepos, nil +}
\ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 7dedee7..cb27058 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,10 +19,11 @@ type Organization struct { // Config holds the application configuration type Config struct { - Organizations []Organization `json:"organizations"` - Repositories []string `json:"repositories,omitempty"` - ExcludeBranches []string `json:"exclude_branches,omitempty"` // Regex patterns for branches to exclude - WorkDir string `json:"work_dir,omitempty"` // Working directory for cloning repositories + Organizations []Organization `json:"organizations"` + Repositories []string `json:"repositories,omitempty"` + ExcludeBranches []string `json:"exclude_branches,omitempty"` // Regex patterns for branches to exclude + WorkDir string `json:"work_dir,omitempty"` // Working directory for cloning repositories + ExcludeFromShowcase []string `json:"exclude_from_showcase,omitempty"` // Repository names to exclude from showcase } // Load reads and parses the configuration file diff --git a/internal/showcase/images.go b/internal/showcase/images.go new file mode 100644 index 0000000..fe5be72 --- /dev/null +++ b/internal/showcase/images.go @@ -0,0 +1,258 @@ +package showcase + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +// extractImagesFromRepo extracts up to 2 images from README.md and copies them to showcase directory +func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, error) { + // Look for README files + readmeFiles := []string{"README.md", "readme.md", "Readme.md", "README.MD"} + var readmePath string + + for _, filename := range readmeFiles { + path := filepath.Join(repoPath, filename) + if _, err := os.Stat(path); err == nil { + readmePath = path + break + } + } + + if readmePath == "" { + return nil, nil // No README found, not an error + } + + // Read README content + content, err := os.ReadFile(readmePath) + if err != nil { + return nil, fmt.Errorf("failed to read README: %w", err) + } + + fmt.Printf("Found README at: %s\n", readmePath) + + // Extract image references + images := extractImageReferences(string(content)) + fmt.Printf("Found %d images in README\n", len(images)) + for i, img := range images { + fmt.Printf(" Image %d: %s\n", i+1, img) + } + + if len(images) == 0 { + return nil, nil + } + + // Limit to first and last image (max 2) + var selectedImages []string + if len(images) == 1 { + selectedImages = images + } else { + selectedImages = []string{images[0], images[len(images)-1]} + } + + // Create showcase subdirectory for this repo + repoShowcaseDir := filepath.Join(showcaseDir, "showcase", repoName) + if err := os.MkdirAll(repoShowcaseDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create showcase directory: %w", err) + } + + // Copy images and collect relative paths + var copiedImages []string + for i, imgPath := range selectedImages { + var destFilename string + var err error + + if strings.HasPrefix(imgPath, "http://") || strings.HasPrefix(imgPath, "https://") { + // Handle URL - download the image + // Extract extension from URL, handling query parameters + urlParts := strings.Split(imgPath, "?") + basePath := urlParts[0] + ext := filepath.Ext(basePath) + if ext == "" || len(ext) > 5 { // Likely not a real extension + ext = ".png" // Default extension + } + destFilename = fmt.Sprintf("image-%d%s", i+1, ext) + destPath := filepath.Join(repoShowcaseDir, destFilename) + + if err = downloadImage(imgPath, destPath); err != nil { + fmt.Printf("Warning: Failed to download image %s: %v\n", imgPath, err) + continue + } + } else { + // Handle local file + srcPath := imgPath + if !filepath.IsAbs(imgPath) { + srcPath = filepath.Join(repoPath, imgPath) + } + + // Check if image exists + if _, err := os.Stat(srcPath); err != nil { + fmt.Printf("Warning: Image not found: %s\n", srcPath) + continue + } + + // Generate destination filename + ext := filepath.Ext(srcPath) + destFilename = fmt.Sprintf("image-%d%s", i+1, ext) + destPath := filepath.Join(repoShowcaseDir, destFilename) + + // Copy image + if err := copyFile(srcPath, destPath); err != nil { + fmt.Printf("Warning: Failed to copy image %s: %v\n", srcPath, err) + continue + } + } + + // Store relative path from showcase directory + relativePath := filepath.Join("showcase", repoName, destFilename) + copiedImages = append(copiedImages, relativePath) + fmt.Printf("Copied/Downloaded image: %s -> %s\n", imgPath, relativePath) + } + + return copiedImages, nil +} + +// extractImageReferences extracts image references from markdown content +func extractImageReferences(content string) []string { + var images []string + seen := make(map[string]bool) + + // Regex patterns for markdown images + patterns := []string{ + `!\[([^\]]*)\]\(([^)]+)\)`, //  + `<img[^>]+src=["']([^"']+)["'][^>]*>`, // <img src="url"> + `!\[([^\]]*)\]\[([^\]]+)\]`, // ![alt][ref] + `\[([^\]]+)\]:\s*(.+?)(?:\s+"[^"]+")?\s*$`, // [ref]: url "title" + } + + fmt.Printf("DEBUG: Content length: %d bytes\n", len(content)) + + // Extract from markdown image syntax + for i, pattern := range patterns[:2] { // First two patterns have URLs in different positions + re := regexp.MustCompile(pattern) + matches := re.FindAllStringSubmatch(content, -1) + fmt.Printf("DEBUG: Pattern %d (%s) found %d matches\n", i, pattern, len(matches)) + + for _, match := range matches { + var url string + if pattern == patterns[0] { + url = match[2] // For  + } else { + url = match[1] // For <img src="url"> + } + + // Clean and validate URL + url = strings.TrimSpace(url) + fmt.Printf("DEBUG: Found potential image URL: %s\n", url) + + if isImageFile(url) { + fmt.Printf("DEBUG: URL is image file\n") + if !seen[url] { + // Handle different types of URLs + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + // Local file + fmt.Printf("DEBUG: Adding local image: %s\n", url) + images = append(images, url) + seen[url] = true + } else if isGitHostedImage(url) { + // GitHub/Codeberg hosted images - we can download these + fmt.Printf("DEBUG: Found git-hosted image: %s\n", url) + images = append(images, url) + seen[url] = true + } else { + fmt.Printf("DEBUG: Skipping external URL: %s\n", url) + } + } + } else { + fmt.Printf("DEBUG: Not recognized as image file: %s\n", url) + } + } + } + + // Handle reference-style images + refPattern := regexp.MustCompile(patterns[3]) + refMatches := refPattern.FindAllStringSubmatch(content, -1) + refs := make(map[string]string) + for _, match := range refMatches { + refs[match[1]] = strings.TrimSpace(match[2]) + } + + // Find reference-style image uses + refUsePattern := regexp.MustCompile(patterns[2]) + refUseMatches := refUsePattern.FindAllStringSubmatch(content, -1) + for _, match := range refUseMatches { + ref := match[2] + if url, ok := refs[ref]; ok && isImageFile(url) && !seen[url] { + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + images = append(images, url) + seen[url] = true + } + } + } + + return images +} + +// isImageFile checks if a URL points to an image file +func isImageFile(url string) bool { + url = strings.ToLower(url) + extensions := []string{".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"} + for _, ext := range extensions { + if strings.HasSuffix(url, ext) { + return true + } + } + return false +} + +// isGitHostedImage checks if URL is from GitHub/Codeberg +func isGitHostedImage(url string) bool { + return strings.Contains(url, "github.com") || + strings.Contains(url, "githubusercontent.com") || + strings.Contains(url, "codeberg.org") || + strings.Contains(url, "codeberg.page") +} + +// copyFile copies a file from src to dst +func copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, sourceFile) + if err != nil { + return err + } + + return destFile.Sync() +} + +// downloadImage downloads an image from URL to dst +func downloadImage(url, dst string) error { + // Use curl to download the image + cmd := exec.Command("curl", "-L", "-o", dst, url) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("curl failed: %v, output: %s", err, string(output)) + } + + // Verify the file was created + if _, err := os.Stat(dst); err != nil { + return fmt.Errorf("downloaded file not found: %v", err) + } + + return nil +}
\ No newline at end of file diff --git a/internal/showcase/metadata.go b/internal/showcase/metadata.go new file mode 100644 index 0000000..209c7b6 --- /dev/null +++ b/internal/showcase/metadata.go @@ -0,0 +1,365 @@ +package showcase + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// RepoMetadata holds metadata about a repository +type RepoMetadata struct { + Languages []string + CommitCount int + LinesOfCode int + FirstCommitDate string + LastCommitDate string + License string + AvgCommitAge float64 // Average age of last 100 commits in days +} + +// extractRepoMetadata extracts metadata from a repository +func extractRepoMetadata(repoPath string) (*RepoMetadata, error) { + metadata := &RepoMetadata{} + + // Get programming languages by analyzing file extensions + languages, err := detectLanguages(repoPath) + if err != nil { + fmt.Printf("Warning: Failed to detect languages: %v\n", err) + } + metadata.Languages = languages + + // Get commit count + commitCount, err := getCommitCount(repoPath) + if err != nil { + fmt.Printf("Warning: Failed to get commit count: %v\n", err) + } + metadata.CommitCount = commitCount + + // Get lines of code + loc, err := countLinesOfCode(repoPath) + if err != nil { + fmt.Printf("Warning: Failed to count lines of code: %v\n", err) + } + metadata.LinesOfCode = loc + + // Get first and last commit dates + firstDate, err := getFirstCommitDate(repoPath) + if err != nil { + fmt.Printf("Warning: Failed to get first commit date: %v\n", err) + } + metadata.FirstCommitDate = firstDate + + lastDate, err := getLastCommitDate(repoPath) + if err != nil { + fmt.Printf("Warning: Failed to get last commit date: %v\n", err) + } + metadata.LastCommitDate = lastDate + + // Check for license file + license := detectLicense(repoPath) + metadata.License = license + + // Get average age of last 100 commits + avgAge, err := getAverageCommitAge(repoPath, 100) + if err != nil { + fmt.Printf("Warning: Failed to get average commit age: %v\n", err) + } + metadata.AvgCommitAge = avgAge + + return metadata, nil +} + +// detectLanguages detects programming languages used in the repository +func detectLanguages(repoPath string) ([]string, error) { + languageMap := make(map[string]bool) + + // Define common language extensions + langExtensions := map[string]string{ + ".go": "Go", + ".py": "Python", + ".js": "JavaScript", + ".ts": "TypeScript", + ".java": "Java", + ".c": "C", + ".cpp": "C++", + ".cc": "C++", + ".h": "C/C++", + ".hpp": "C++", + ".cs": "C#", + ".rb": "Ruby", + ".php": "PHP", + ".swift": "Swift", + ".kt": "Kotlin", + ".rs": "Rust", + ".scala": "Scala", + ".r": "R", + ".m": "Objective-C", + ".mm": "Objective-C++", + ".sh": "Shell", + ".bash": "Bash", + ".zsh": "Zsh", + ".pl": "Perl", + ".lua": "Lua", + ".vim": "Vim Script", + ".el": "Emacs Lisp", + ".clj": "Clojure", + ".hs": "Haskell", + ".ml": "OCaml", + ".ex": "Elixir", + ".exs": "Elixir", + ".dart": "Dart", + ".jl": "Julia", + ".nim": "Nim", + ".v": "V", + ".zig": "Zig", + } + + // Walk through the repository + err := filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + + // Skip hidden directories and common non-code directories + if info.IsDir() { + name := info.Name() + if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" || name == "target" || name == "dist" || name == "build" { + return filepath.SkipDir + } + return nil + } + + // Check file extension + ext := strings.ToLower(filepath.Ext(path)) + if lang, ok := langExtensions[ext]; ok { + languageMap[lang] = true + } + + // Check for special files + basename := filepath.Base(path) + switch strings.ToLower(basename) { + case "makefile", "gnumakefile": + languageMap["Make"] = true + case "dockerfile": + languageMap["Docker"] = true + case "cmakelists.txt": + languageMap["CMake"] = true + case "rakefile": + languageMap["Ruby"] = true + case "gemfile": + languageMap["Ruby"] = true + case "package.json": + languageMap["JavaScript/Node.js"] = true + case "cargo.toml": + languageMap["Rust"] = true + case "go.mod": + languageMap["Go"] = true + case "pom.xml": + languageMap["Java/Maven"] = true + case "build.gradle", "build.gradle.kts": + languageMap["Java/Gradle"] = true + case "requirements.txt", "setup.py", "pyproject.toml": + languageMap["Python"] = true + case "composer.json": + languageMap["PHP"] = true + } + + return nil + }) + + if err != nil { + return nil, err + } + + // Convert map to slice + var languages []string + for lang := range languageMap { + languages = append(languages, lang) + } + + return languages, nil +} + +// getCommitCount returns the total number of commits +func getCommitCount(repoPath string) (int, error) { + cmd := exec.Command("git", "-C", repoPath, "rev-list", "--all", "--count") + output, err := cmd.Output() + if err != nil { + return 0, err + } + + count, err := strconv.Atoi(strings.TrimSpace(string(output))) + if err != nil { + return 0, err + } + + return count, nil +} + +// countLinesOfCode counts lines of code (excluding binary files and common non-code files) +func countLinesOfCode(repoPath string) (int, error) { + // Use git ls-files to get tracked files, then count lines + // Exclude binary files and common non-code files + cmd := exec.Command("bash", "-c", fmt.Sprintf( + `cd "%s" && git ls-files | grep -E '\.(go|py|js|ts|java|c|cpp|h|hpp|cs|rb|php|swift|kt|rs|scala|r|sh|bash|zsh|pl|lua|vim|el|clj|hs|ml|ex|exs|dart|jl|nim|v|zig|html|css|scss|sass|json|xml|yaml|yml|toml|ini|conf|cfg)$' | xargs wc -l 2>/dev/null | tail -n 1 | awk '{print $1}'`, + repoPath, + )) + + output, err := cmd.Output() + if err != nil { + // Fallback: try a simpler approach + cmd = exec.Command("bash", "-c", fmt.Sprintf( + `find "%s" -type f -name "*.go" -o -name "*.py" -o -name "*.js" -o -name "*.java" -o -name "*.c" -o -name "*.cpp" -o -name "*.rs" | xargs wc -l 2>/dev/null | tail -n 1 | awk '{print $1}'`, + repoPath, + )) + output, err = cmd.Output() + if err != nil { + return 0, err + } + } + + loc, err := strconv.Atoi(strings.TrimSpace(string(output))) + if err != nil { + return 0, err + } + + return loc, nil +} + +// getFirstCommitDate returns the date of the first commit +func getFirstCommitDate(repoPath string) (string, error) { + cmd := exec.Command("git", "-C", repoPath, "log", "--reverse", "--pretty=format:%ai", "--date=short") + output, err := cmd.Output() + if err != nil { + return "", err + } + + lines := strings.Split(string(output), "\n") + if len(lines) > 0 && lines[0] != "" { + // Extract just the date part (YYYY-MM-DD) + parts := strings.Fields(lines[0]) + if len(parts) > 0 { + return parts[0], nil + } + } + + return "", fmt.Errorf("no commits found") +} + +// getLastCommitDate returns the date of the last commit +func getLastCommitDate(repoPath string) (string, error) { + cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--pretty=format:%ai", "--date=short") + output, err := cmd.Output() + if err != nil { + return "", err + } + + // Extract just the date part (YYYY-MM-DD) + parts := strings.Fields(string(output)) + if len(parts) > 0 { + return parts[0], nil + } + + return "", fmt.Errorf("no commits found") +} + +// detectLicense checks for common license files +func detectLicense(repoPath string) string { + licenseFiles := []string{ + "LICENSE", + "LICENSE.txt", + "LICENSE.md", + "license", + "license.txt", + "license.md", + "COPYING", + "COPYING.txt", + "COPYRIGHT", + "COPYRIGHT.txt", + } + + for _, filename := range licenseFiles { + path := filepath.Join(repoPath, filename) + if info, err := os.Stat(path); err == nil && !info.IsDir() { + // Try to detect license type by reading the file + content, err := os.ReadFile(path) + if err == nil { + contentStr := string(content) + switch { + case strings.Contains(contentStr, "MIT License"): + return "MIT" + case strings.Contains(contentStr, "Apache License") && strings.Contains(contentStr, "Version 2.0"): + return "Apache-2.0" + case strings.Contains(contentStr, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(contentStr, "Version 3"): + return "GPL-3.0" + case strings.Contains(contentStr, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(contentStr, "Version 2"): + return "GPL-2.0" + case strings.Contains(contentStr, "BSD 3-Clause License"): + return "BSD-3-Clause" + case strings.Contains(contentStr, "BSD 2-Clause License"): + return "BSD-2-Clause" + case strings.Contains(contentStr, "Mozilla Public License Version 2.0"): + return "MPL-2.0" + case strings.Contains(contentStr, "ISC License"): + return "ISC" + case strings.Contains(contentStr, "GNU LESSER GENERAL PUBLIC LICENSE"): + return "LGPL" + case strings.Contains(contentStr, "The Unlicense"): + return "Unlicense" + case strings.Contains(contentStr, "CC0"): + return "CC0" + default: + return "Custom License" + } + } + return "License file found" + } + } + + return "No license found" +} + +// getAverageCommitAge calculates the average age of the last N commits in days +func getAverageCommitAge(repoPath string, commitCount int) (float64, error) { + // Get the last N commit dates + cmd := exec.Command("git", "-C", repoPath, "log", fmt.Sprintf("-%d", commitCount), "--pretty=format:%at") + output, err := cmd.Output() + if err != nil { + return 0, err + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) == 0 || lines[0] == "" { + return 0, fmt.Errorf("no commits found") + } + + // Calculate average age + now := float64(time.Now().Unix()) + var totalAge float64 + validCommits := 0 + + for _, line := range lines { + if line == "" { + continue + } + + timestamp, err := strconv.ParseInt(line, 10, 64) + if err != nil { + continue + } + + age := (now - float64(timestamp)) / 86400 // Convert to days + totalAge += age + validCommits++ + } + + if validCommits == 0 { + return 0, fmt.Errorf("no valid commits found") + } + + return totalAge / float64(validCommits), nil +}
\ No newline at end of file diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go new file mode 100644 index 0000000..9c5afa6 --- /dev/null +++ b/internal/showcase/showcase.go @@ -0,0 +1,560 @@ +package showcase + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "codeberg.org/snonux/gitsyncer/internal/config" +) + +// Generator handles showcase generation for repositories +type Generator struct { + config *config.Config + workDir string +} + +// ProjectSummary holds the summary information for a project +type ProjectSummary struct { + Name string + Summary string + CodebergURL string + GitHubURL string + Metadata *RepoMetadata + Images []string // Relative paths to images in showcase directory +} + +// New creates a new showcase generator +func New(cfg *config.Config, workDir string) *Generator { + return &Generator{ + config: cfg, + workDir: workDir, + } +} + +// GenerateShowcase generates a showcase for repositories +// If repoFilter is provided, only those repositories are processed +// If repoFilter is empty/nil, all repositories in work directory are processed +func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool) error { + var repos []string + var err error + + if len(repoFilter) > 0 { + // Use the provided filter + repos = repoFilter + } else { + // Get all repositories in work directory + repos, err = g.getRepositories() + if err != nil { + return fmt.Errorf("failed to get repositories: %w", err) + } + } + + if len(repos) == 0 { + return fmt.Errorf("no repositories found") + } + + // Filter out excluded repositories + filteredRepos := g.filterExcludedRepos(repos) + + fmt.Printf("Found %d repositories to process (after filtering %d excluded)\n", + len(filteredRepos), len(repos)-len(filteredRepos)) + + // Generate summaries for each repository + summaries := make([]ProjectSummary, 0, len(filteredRepos)) + successCount := 0 + + for i, repo := range filteredRepos { + fmt.Printf("\n[%d/%d] Processing %s...\n", i+1, len(filteredRepos), repo) + + summary, err := g.generateProjectSummary(repo, forceRegenerate) + if err != nil { + fmt.Printf("WARNING: Failed to generate summary for %s: %v\n", repo, err) + continue + } + + // Print the generated summary to stdout + fmt.Printf("\n--- Generated summary for %s ---\n", repo) + fmt.Println(summary.Summary) + if summary.Metadata != nil { + fmt.Printf("Languages: %s\n", strings.Join(summary.Metadata.Languages, ", ")) + fmt.Printf("Commits: %d\n", summary.Metadata.CommitCount) + fmt.Printf("Lines of Code: %d\n", summary.Metadata.LinesOfCode) + fmt.Printf("First Commit: %s\n", summary.Metadata.FirstCommitDate) + fmt.Printf("Last Commit: %s\n", summary.Metadata.LastCommitDate) + fmt.Printf("License: %s\n", summary.Metadata.License) + fmt.Printf("Avg. age of last 100 commits: %.1f days\n", summary.Metadata.AvgCommitAge) + } + fmt.Println("--- End of summary ---") + + summaries = append(summaries, *summary) + successCount++ + } + + if successCount == 0 { + return fmt.Errorf("failed to generate any summaries") + } + + fmt.Printf("\nSuccessfully generated %d/%d summaries\n", successCount, len(repos)) + + // Sort summaries by average commit age (newest first) + sort.Slice(summaries, func(i, j int) bool { + // If metadata is missing, put at the end + if summaries[i].Metadata == nil { + return false + } + if summaries[j].Metadata == nil { + return true + } + // Lower average age means more recent activity + return summaries[i].Metadata.AvgCommitAge < summaries[j].Metadata.AvgCommitAge + }) + + // When filtering (single repo), we need to update existing showcase + if len(repoFilter) > 0 { + if err := g.updateShowcaseFile(summaries); err != nil { + return fmt.Errorf("failed to update showcase file: %w", err) + } + } else { + // Full regeneration - format as Gemtext and write + content := g.formatGemtext(summaries) + if err := g.writeShowcaseFile(content); err != nil { + return fmt.Errorf("failed to write showcase file: %w", err) + } + } + + return nil +} + +// getRepositories returns a list of repository directories in the work directory +func (g *Generator) getRepositories() ([]string, error) { + entries, err := os.ReadDir(g.workDir) + if err != nil { + return nil, err |
