From 11eea6a82cbfdde40ec1457c6ea080da4da6b7dc Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 31 Oct 2025 20:13:32 +0200 Subject: feat: implement amp AI tool support and replace Taskfile with Mage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add amp as default AI tool for release notes and showcase generation - Fallback chain: amp → hexai → claude → aichat - Replace Taskfile.yaml with magefile.go for build automation - Update all documentation (README.md, AGENTS.md, doc/development.md) - Update version to 0.10.0 Amp-Thread-ID: https://ampcode.com/threads/T-735ba1e2-0255-4b43-8ed1-6c0d2f78301b Co-authored-by: Amp --- internal/showcase/ai_context.go | 338 ++++++++++++++++++--------------- internal/showcase/code_extractor.go | 228 +++++++++++----------- internal/showcase/images.go | 78 ++++---- internal/showcase/language_detector.go | 214 ++++++++++----------- internal/showcase/metadata.go | 33 ++-- internal/showcase/showcase.go | 311 +++++++++++++++++------------- 6 files changed, 636 insertions(+), 566 deletions(-) (limited to 'internal/showcase') diff --git a/internal/showcase/ai_context.go b/internal/showcase/ai_context.go index f418894..1c812f8 100644 --- a/internal/showcase/ai_context.go +++ b/internal/showcase/ai_context.go @@ -1,184 +1,214 @@ package showcase import ( - "fmt" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" ) // buildAIInputContext prepares a textual context for AI tools when no README exists. // It returns a string to be piped to the AI tool's stdin and a boolean indicating // whether this was sourced from an actual README (true) or synthesized (false). func buildAIInputContext(repoPath string) (string, bool) { - // 1) Try to load a README first - readmeFiles := []string{ - "README.md", "readme.md", "Readme.md", - "README.MD", "README.txt", "readme.txt", - "README", "readme", - } - for _, f := range readmeFiles { - p := filepath.Join(repoPath, f) - if b, err := os.ReadFile(p); err == nil { - return string(b), true - } - } + // 1) Try to load a README first + readmeFiles := []string{ + "README.md", "readme.md", "Readme.md", + "README.MD", "README.txt", "readme.txt", + "README", "readme", + } + for _, f := range readmeFiles { + p := filepath.Join(repoPath, f) + if b, err := os.ReadFile(p); err == nil { + return string(b), true + } + } - // 2) No README: synthesize compact context - var sb strings.Builder + // 2) No README: synthesize compact context + var sb strings.Builder - // File tree (depth-limited) - sb.WriteString("[CONTEXT]\n") - sb.WriteString("Repository does not contain a README.\n") - sb.WriteString("The following is a compact file tree and key manifests/snippets.\n\n") + // File tree (depth-limited) + sb.WriteString("[CONTEXT]\n") + sb.WriteString("Repository does not contain a README.\n") + sb.WriteString("The following is a compact file tree and key manifests/snippets.\n\n") - sb.WriteString("FILE TREE (depth 2):\n") - tree := listFileTree(repoPath, 2, 200) - for _, line := range tree { - sb.WriteString("- ") - sb.WriteString(line) - sb.WriteString("\n") - } - sb.WriteString("\n") + sb.WriteString("FILE TREE (depth 2):\n") + tree := listFileTree(repoPath, 2, 200) + for _, line := range tree { + sb.WriteString("- ") + sb.WriteString(line) + sb.WriteString("\n") + } + sb.WriteString("\n") - // Key manifests we often care about - manifests := []string{ - "go.mod", "go.sum", "package.json", "Cargo.toml", "Cargo.lock", - "pyproject.toml", "requirements.txt", "Makefile", "Dockerfile", - "build.gradle", "pom.xml", "composer.json", - } - wroteHeader := false - for _, m := range manifests { - p := filepath.Join(repoPath, m) - if b, err := os.ReadFile(p); err == nil { - if !wroteHeader { - sb.WriteString("KEY MANIFESTS:\n") - wroteHeader = true - } - sb.WriteString(fmt.Sprintf("--- %s ---\n", m)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } + // Key manifests we often care about + manifests := []string{ + "go.mod", "go.sum", "package.json", "Cargo.toml", "Cargo.lock", + "pyproject.toml", "requirements.txt", "Makefile", "Dockerfile", + "build.gradle", "pom.xml", "composer.json", + } + wroteHeader := false + for _, m := range manifests { + p := filepath.Join(repoPath, m) + if b, err := os.ReadFile(p); err == nil { + if !wroteHeader { + sb.WriteString("KEY MANIFESTS:\n") + wroteHeader = true + } + sb.WriteString(fmt.Sprintf("--- %s ---\n", m)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } - // Source hints: capture first main-ish entry file snippets - // Priority: Go main, Rust main, Node entry, Python main - candidates := []string{ - "cmd", // Go convention - "main.go", - "cmd/main.go", - "src/main.rs", - "index.js", - "src/index.js", - "main.py", - "src/main.py", - } - wroteSrc := false - for _, c := range candidates { - p := filepath.Join(repoPath, c) - info, err := os.Stat(p) - if err != nil { - continue - } - if info.IsDir() { - // collect a few go files under cmd/*/main.go - if c == "cmd" { - _ = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { - if err != nil { return nil } - if d.IsDir() { return nil } - base := filepath.Base(path) - if base == "main.go" { - if b, e := os.ReadFile(path); e == nil { - if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true } - rel, _ := filepath.Rel(repoPath, path) - sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } - return nil - }) - } - continue - } - if b, e := os.ReadFile(p); e == nil { - if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true } - rel, _ := filepath.Rel(repoPath, p) - sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } + // Source hints: capture first main-ish entry file snippets + // Priority: Go main, Rust main, Node entry, Python main + candidates := []string{ + "cmd", // Go convention + "main.go", + "cmd/main.go", + "src/main.rs", + "index.js", + "src/index.js", + "main.py", + "src/main.py", + } + wroteSrc := false + for _, c := range candidates { + p := filepath.Join(repoPath, c) + info, err := os.Stat(p) + if err != nil { + continue + } + if info.IsDir() { + // collect a few go files under cmd/*/main.go + if c == "cmd" { + _ = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + return nil + } + base := filepath.Base(path) + if base == "main.go" { + if b, e := os.ReadFile(path); e == nil { + if !wroteSrc { + sb.WriteString("PRIMARY SOURCE SNIPPETS:\n") + wroteSrc = true + } + rel, _ := filepath.Rel(repoPath, path) + sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } + return nil + }) + } + continue + } + if b, e := os.ReadFile(p); e == nil { + if !wroteSrc { + sb.WriteString("PRIMARY SOURCE SNIPPETS:\n") + wroteSrc = true + } + rel, _ := filepath.Rel(repoPath, p) + sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } - // Fallback: include a few top-level .go, .rs, .py, .js files if we still have nothing - if !wroteSrc { - topFiles := listTopFiles(repoPath, []string{".go", ".rs", ".py", ".js", ".ts", ".tsx"}, 5) - for _, f := range topFiles { - if b, e := os.ReadFile(filepath.Join(repoPath, f)); e == nil { - if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true } - sb.WriteString(fmt.Sprintf("--- %s ---\n", f)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } - } + // Fallback: include a few top-level .go, .rs, .py, .js files if we still have nothing + if !wroteSrc { + topFiles := listTopFiles(repoPath, []string{".go", ".rs", ".py", ".js", ".ts", ".tsx"}, 5) + for _, f := range topFiles { + if b, e := os.ReadFile(filepath.Join(repoPath, f)); e == nil { + if !wroteSrc { + sb.WriteString("PRIMARY SOURCE SNIPPETS:\n") + wroteSrc = true + } + sb.WriteString(fmt.Sprintf("--- %s ---\n", f)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } + } - // Instruction to the model - sb.WriteString("[TASK]\n") - sb.WriteString("Summarize this project in 1–2 paragraphs: what it does, why it's useful, and how it's implemented. Mention notable tech choices. Be concise and informative.\n") + // Instruction to the model + sb.WriteString("[TASK]\n") + sb.WriteString("Summarize this project in 1–2 paragraphs: what it does, why it's useful, and how it's implemented. Mention notable tech choices. Be concise and informative.\n") - return sb.String(), false + return sb.String(), false } // listFileTree returns a sorted list of relative paths up to a given depth and limit. func listFileTree(root string, maxDepth int, maxEntries int) []string { - var entries []string - var count int - _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { return nil } - if path == root { return nil } - rel, e := filepath.Rel(root, path) - if e != nil { return nil } - // depth check - depth := 1 + strings.Count(rel, string(os.PathSeparator)) - if depth > maxDepth { return fs.SkipDir } - entries = append(entries, rel) - count++ - if count >= maxEntries { return fs.SkipDir } - return nil - }) - sort.Strings(entries) - if len(entries) > maxEntries { - entries = entries[:maxEntries] - } - return entries + var entries []string + var count int + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if path == root { + return nil + } + rel, e := filepath.Rel(root, path) + if e != nil { + return nil + } + // depth check + depth := 1 + strings.Count(rel, string(os.PathSeparator)) + if depth > maxDepth { + return fs.SkipDir + } + entries = append(entries, rel) + count++ + if count >= maxEntries { + return fs.SkipDir + } + return nil + }) + sort.Strings(entries) + if len(entries) > maxEntries { + entries = entries[:maxEntries] + } + return entries } // listTopFiles lists top-level files with certain extensions up to a limit. func listTopFiles(root string, exts []string, limit int) []string { - dir, err := os.ReadDir(root) - if err != nil { return nil } - var out []string - for _, e := range dir { - if e.IsDir() { continue } - name := e.Name() - for _, x := range exts { - if strings.HasSuffix(strings.ToLower(name), strings.ToLower(x)) { - out = append(out, name) - break - } - } - if len(out) >= limit { break } - } - sort.Strings(out) - return out + dir, err := os.ReadDir(root) + if err != nil { + return nil + } + var out []string + for _, e := range dir { + if e.IsDir() { + continue + } + name := e.Name() + for _, x := range exts { + if strings.HasSuffix(strings.ToLower(name), strings.ToLower(x)) { + out = append(out, name) + break + } + } + if len(out) >= limit { + break + } + } + sort.Strings(out) + return out } // trimTo soft-limits content length for inclusion in AI context. func trimTo(s string, max int) string { - if len(s) <= max { return s } - return s[:max] + "\n... [truncated]" + if len(s) <= max { + return s + } + return s[:max] + "\n... [truncated]" } - diff --git a/internal/showcase/code_extractor.go b/internal/showcase/code_extractor.go index 91a0a78..fbf17f6 100644 --- a/internal/showcase/code_extractor.go +++ b/internal/showcase/code_extractor.go @@ -22,34 +22,34 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str // Get the primary language (highest percentage) primaryLang := languages[0].Name - + // Define file extensions for each language langExtensions := map[string][]string{ - "Go": {".go"}, - "Python": {".py"}, - "JavaScript": {".js"}, - "TypeScript": {".ts"}, - "Java": {".java"}, - "C": {".c", ".h"}, - "C++": {".cpp", ".cc", ".cxx", ".hpp"}, - "C/C++": {".h"}, - "C#": {".cs"}, - "Ruby": {".rb"}, - "PHP": {".php"}, - "Swift": {".swift"}, - "Kotlin": {".kt"}, - "Rust": {".rs"}, - "Shell": {".sh", ".bash"}, - "Perl": {".pl", ".pm"}, - "Raku": {".raku", ".rakumod", ".p6", ".pm6"}, - "Haskell": {".hs"}, - "Lua": {".lua"}, - "HTML": {".html", ".htm"}, - "CSS": {".css"}, - "SQL": {".sql"}, - "Make": {"Makefile", "makefile", "GNUmakefile"}, - "HCL": {".tf", ".tfvars", ".hcl"}, - "AWK": {".awk", ".cgi"}, // .cgi files can be AWK scripts + "Go": {".go"}, + "Python": {".py"}, + "JavaScript": {".js"}, + "TypeScript": {".ts"}, + "Java": {".java"}, + "C": {".c", ".h"}, + "C++": {".cpp", ".cc", ".cxx", ".hpp"}, + "C/C++": {".h"}, + "C#": {".cs"}, + "Ruby": {".rb"}, + "PHP": {".php"}, + "Swift": {".swift"}, + "Kotlin": {".kt"}, + "Rust": {".rs"}, + "Shell": {".sh", ".bash"}, + "Perl": {".pl", ".pm"}, + "Raku": {".raku", ".rakumod", ".p6", ".pm6"}, + "Haskell": {".hs"}, + "Lua": {".lua"}, + "HTML": {".html", ".htm"}, + "CSS": {".css"}, + "SQL": {".sql"}, + "Make": {"Makefile", "makefile", "GNUmakefile"}, + "HCL": {".tf", ".tfvars", ".hcl"}, + "AWK": {".awk", ".cgi"}, // .cgi files can be AWK scripts } // Get file extensions for the primary language @@ -79,13 +79,13 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str if info.IsDir() { name := info.Name() // Skip hidden directories and common non-code directories - if strings.HasPrefix(name, ".") && name != "." || - name == "node_modules" || - name == "vendor" || - name == "target" || - name == "dist" || - name == "build" || - name == "__pycache__" { + if strings.HasPrefix(name, ".") && name != "." || + name == "node_modules" || + name == "vendor" || + name == "target" || + name == "dist" || + name == "build" || + name == "__pycache__" { return filepath.SkipDir } return nil @@ -99,7 +99,7 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str // Check if file matches extensions basename := filepath.Base(path) ext := filepath.Ext(path) - + matched := false for _, validExt := range extensions { if validExt == basename || (strings.HasPrefix(validExt, ".") && ext == validExt) { @@ -107,7 +107,7 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str break } } - + // For executable files, also check shebang if primary language is AWK and file has .cgi extension if !matched && primaryLang == "AWK" && ext == ".cgi" && info.Mode()&0111 != 0 { if file, err := os.Open(path); err == nil { @@ -121,14 +121,14 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str file.Close() } } - + if matched { // Skip test files and generated files - if !strings.Contains(basename, "_test") && - !strings.Contains(basename, ".test.") && - !strings.Contains(basename, ".min.") && - !strings.Contains(path, "/test/") && - !strings.Contains(path, "/tests/") { + if !strings.Contains(basename, "_test") && + !strings.Contains(basename, ".test.") && + !strings.Contains(basename, ".min.") && + !strings.Contains(path, "/test/") && + !strings.Contains(path, "/tests/") { codeFiles = append(codeFiles, path) } } @@ -148,10 +148,10 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str rand.Shuffle(len(codeFiles), func(i, j int) { codeFiles[i], codeFiles[j] = codeFiles[j], codeFiles[i] }) - + var snippet string var selectedFile string - + // Try up to 5 files to find a good snippet for i := 0; i < len(codeFiles) && i < 5; i++ { candidateFile := codeFiles[i] @@ -159,28 +159,28 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str if err != nil { continue } - + // Check if this snippet has acceptable line lengths if hasAcceptableLineLength(candidateSnippet, 80) { snippet = candidateSnippet selectedFile = candidateFile break } - + // Keep the first valid snippet as fallback if snippet == "" { snippet = candidateSnippet selectedFile = candidateFile } } - + if snippet == "" { return "", "", fmt.Errorf("no valid code snippets found") } // Get relative path for display relPath, _ := filepath.Rel(repoPath, selectedFile) - + return snippet, fmt.Sprintf("%s from `%s`", primaryLang, relPath), nil } @@ -236,9 +236,9 @@ func extractSnippetFromFile(filePath string, minLines, maxLines int) (string, er skipLines := 0 for i, line := range lines { trimmed := strings.TrimSpace(line) - if trimmed != "" && !strings.HasPrefix(trimmed, "import") && - !strings.HasPrefix(trimmed, "package") && !strings.HasPrefix(trimmed, "using") && - !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "from") { + if trimmed != "" && !strings.HasPrefix(trimmed, "import") && + !strings.HasPrefix(trimmed, "package") && !strings.HasPrefix(trimmed, "using") && + !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "from") { skipLines = i break } @@ -260,19 +260,19 @@ func findSmallestCompleteFunction(lines []string) string { end int size int } - + var functions []functionInfo - + // Keywords that typically start functions/methods functionKeywords := []string{ "func ", "function ", "def ", "public ", "private ", "protected ", "static ", "async ", "procedure ", "sub ", "method ", } - + // Find all complete functions for i := 0; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) - + // Check if this line starts a function isFunction := false for _, keyword := range functionKeywords { @@ -281,11 +281,11 @@ func findSmallestCompleteFunction(lines []string) string { break } } - + if !isFunction { continue } - + // Try to find the end of this function functionEnd := findFunctionEnd(lines, i) if functionEnd > i { @@ -300,7 +300,7 @@ func findSmallestCompleteFunction(lines []string) string { } } } - + // Find the smallest function with acceptable line lengths if len(functions) > 0 { // First try to find a function with all lines <= 80 chars @@ -310,7 +310,7 @@ func findSmallestCompleteFunction(lines []string) string { return snippet } } - + // If none found, return the smallest function (will be broken later) smallest := functions[0] for _, f := range functions[1:] { @@ -320,7 +320,7 @@ func findSmallestCompleteFunction(lines []string) string { } return strings.Join(lines[smallest.start:smallest.end+1], "\n") } - + return "" } @@ -329,11 +329,11 @@ func findFunctionEnd(lines []string, start int) int { if start >= len(lines) { return -1 } - + // For brace-based languages braceCount := 0 inFunction := false - + // For Python - track initial indentation isPython := strings.Contains(lines[start], "def ") || strings.Contains(lines[start], "class ") var initialIndent int @@ -346,11 +346,11 @@ func findFunctionEnd(lines []string, start int) int { } } } - + for i := start; i < len(lines); i++ { line := lines[i] trimmed := strings.TrimSpace(line) - + // Handle Python indentation if isPython && i > start { if trimmed == "" { @@ -361,7 +361,7 @@ func findFunctionEnd(lines []string, start int) int { return i - 1 } } - + // Handle brace-based languages for _, ch := range line { if ch == '{' { @@ -375,12 +375,12 @@ func findFunctionEnd(lines []string, start int) int { } } } - + // If we're in Python and reached the end, return the last line if isPython { return len(lines) - 1 } - + return -1 } @@ -391,11 +391,11 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int, "func ", "function ", "def ", "public ", "private ", "protected ", "static ", "async ", "procedure ", "sub ", "method ", } - + // Try to find a function that fits within our size constraints for i := 0; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) - + // Check if this line starts a function isFunction := false for _, keyword := range functionKeywords { @@ -404,11 +404,11 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int, break } } - + if !isFunction { continue } - + // Try to find the end of this function functionEnd := findFunctionEnd(lines, i) if functionEnd > i { @@ -418,7 +418,7 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int, } } } - + return -1, -1 } @@ -435,7 +435,7 @@ func findInterestingStart(lines []string, snippetSize int) int { line := strings.TrimSpace(lines[i]) // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") || - strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") { + strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") { continue } @@ -457,10 +457,10 @@ func stripComments(code string) string { lines := strings.Split(code, "\n") var result []string inMultilineComment := false - + for _, line := range lines { trimmed := strings.TrimSpace(line) - + // Handle multi-line comments for C-style languages if strings.Contains(line, "/*") { inMultilineComment = true @@ -475,19 +475,19 @@ func stripComments(code string) string { continue } } - + if inMultilineComment { if strings.Contains(line, "*/") { inMultilineComment = false } continue } - + // Skip single-line comments if trimmed == "" { // Keep empty lines for readability result = append(result, line) - } else if strings.HasPrefix(trimmed, "//") || + } else if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "#define") || strings.HasPrefix(trimmed, "