A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

try and fetch from github/gitlab/tangled READMEs

+684 -19
+4 -4
Dockerfile.appview
··· 1 1 # Production build for ATCR AppView 2 2 # Result: ~30MB scratch image with static binary 3 - FROM docker.io/golang:1.25.2-trixie AS builder 3 + FROM docker.io/golang:1.25.4-trixie AS builder 4 4 5 5 ENV DEBIAN_FRONTEND=noninteractive 6 6 ··· 34 34 LABEL org.opencontainers.image.title="ATCR AppView" \ 35 35 org.opencontainers.image.description="ATProto Container Registry - OCI-compliant registry using AT Protocol for manifest storage" \ 36 36 org.opencontainers.image.authors="ATCR Contributors" \ 37 - org.opencontainers.image.source="https://tangled.org/@evan.jarrett.net/at-container-registry" \ 38 - org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \ 37 + org.opencontainers.image.source="https://tangled.org/evan.jarrett.net/at-container-registry" \ 38 + org.opencontainers.image.documentation="https://tangled.org/evan.jarrett.net/at-container-registry" \ 39 39 org.opencontainers.image.licenses="MIT" \ 40 40 org.opencontainers.image.version="0.1.0" \ 41 41 io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" \ 42 - io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/appview.md" 42 + io.atcr.readme="https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/docs/appview.md" 43 43 44 44 ENTRYPOINT ["/atcr-appview"] 45 45 CMD ["serve"]
+1 -1
Dockerfile.dev
··· 1 1 # Development image with Air hot reload 2 2 # Build: docker build -f Dockerfile.dev -t atcr-appview-dev . 3 3 # Run: docker run -v $(pwd):/app -p 5000:5000 atcr-appview-dev 4 - FROM docker.io/golang:1.25.2-trixie 4 + FROM docker.io/golang:1.25.4-trixie 5 5 6 6 ENV DEBIAN_FRONTEND=noninteractive 7 7
+4 -4
Dockerfile.hold
··· 1 - FROM docker.io/golang:1.25.2-trixie AS builder 1 + FROM docker.io/golang:1.25.4-trixie AS builder 2 2 3 3 ENV DEBIAN_FRONTEND=noninteractive 4 4 ··· 38 38 LABEL org.opencontainers.image.title="ATCR Hold Service" \ 39 39 org.opencontainers.image.description="ATCR Hold Service - Bring Your Own Storage component for ATCR" \ 40 40 org.opencontainers.image.authors="ATCR Contributors" \ 41 - org.opencontainers.image.source="https://tangled.org/@evan.jarrett.net/at-container-registry" \ 42 - org.opencontainers.image.documentation="https://tangled.org/@evan.jarrett.net/at-container-registry" \ 41 + org.opencontainers.image.source="https://tangled.org/evan.jarrett.net/at-container-registry" \ 42 + org.opencontainers.image.documentation="https://tangled.org/evan.jarrett.net/at-container-registry" \ 43 43 org.opencontainers.image.licenses="MIT" \ 44 44 org.opencontainers.image.version="0.1.0" \ 45 45 io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE" \ 46 - io.atcr.readme="https://tangled.org/@evan.jarrett.net/at-container-registry/raw/main/docs/hold.md" 46 + io.atcr.readme="https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/docs/hold.md" 47 47 48 48 ENTRYPOINT ["/atcr-hold"]
+17 -8
pkg/appview/handlers/repository.go
··· 192 192 193 193 // Fetch README content if available 194 194 var readmeHTML template.HTML 195 - if repo.ReadmeURL != "" && h.ReadmeCache != nil { 196 - // Fetch with timeout 195 + if h.ReadmeCache != nil { 197 196 ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 198 197 defer cancel() 199 198 200 - html, err := h.ReadmeCache.Get(ctx, repo.ReadmeURL) 201 - if err != nil { 202 - slog.Warn("Failed to fetch README", "url", repo.ReadmeURL, "error", err) 203 - // Continue without README on error 204 - } else { 205 - readmeHTML = template.HTML(html) 199 + if repo.ReadmeURL != "" { 200 + // Explicit io.atcr.readme takes priority 201 + html, err := h.ReadmeCache.Get(ctx, repo.ReadmeURL) 202 + if err != nil { 203 + slog.Warn("Failed to fetch README", "url", repo.ReadmeURL, "error", err) 204 + } else { 205 + readmeHTML = template.HTML(html) 206 + } 207 + } else if repo.SourceURL != "" { 208 + // Derive README from org.opencontainers.image.source 209 + html, err := h.ReadmeCache.GetFromSource(ctx, repo.SourceURL) 210 + if err != nil { 211 + slog.Debug("Failed to derive README from source", "url", repo.SourceURL, "error", err) 212 + } else if html != "" { 213 + readmeHTML = template.HTML(html) 214 + } 206 215 } 207 216 } 208 217
+64
pkg/appview/readme/cache.go
··· 11 11 "time" 12 12 ) 13 13 14 + const ( 15 + // negativeCacheTTL is the TTL for negative cache entries (no README found) 16 + negativeCacheTTL = 15 * time.Minute 17 + // sourceCachePrefix is the prefix for source-derived cache keys 18 + sourceCachePrefix = "source:" 19 + ) 20 + 14 21 // Cache stores rendered README HTML in the database 15 22 type Cache struct { 16 23 db *sql.DB ··· 55 62 if err := c.storeInDB(readmeURL, html); err != nil { 56 63 // Log error but don't fail - we have the content 57 64 slog.Warn("Failed to cache README", "error", err) 65 + } 66 + 67 + return html, nil 68 + } 69 + 70 + // GetFromSource fetches a README by deriving the URL from a source repository URL. 71 + // It tries main branch first, then falls back to master if 404. 72 + // Returns empty string if no README found (cached as negative result with shorter TTL). 73 + func (c *Cache) GetFromSource(ctx context.Context, sourceURL string) (string, error) { 74 + cacheKey := sourceCachePrefix + sourceURL 75 + 76 + // Try to get from cache 77 + html, fetchedAt, err := c.getFromDB(cacheKey) 78 + if err == nil { 79 + // Determine TTL based on whether this is a negative cache entry 80 + ttl := c.ttl 81 + if html == "" { 82 + ttl = negativeCacheTTL 83 + } 84 + if time.Since(fetchedAt) < ttl { 85 + return html, nil 86 + } 87 + } 88 + 89 + // Derive README URL and fetch 90 + // Try main branch first 91 + readmeURL := DeriveReadmeURL(sourceURL, "main") 92 + if readmeURL == "" { 93 + return "", nil // Unsupported platform, don't cache 94 + } 95 + 96 + html, err = c.fetcher.FetchAndRender(ctx, readmeURL) 97 + if err != nil { 98 + if Is404(err) { 99 + // Try master branch 100 + readmeURL = DeriveReadmeURL(sourceURL, "master") 101 + html, err = c.fetcher.FetchAndRender(ctx, readmeURL) 102 + if err != nil { 103 + if Is404(err) { 104 + // No README on either branch - cache negative result 105 + if cacheErr := c.storeInDB(cacheKey, ""); cacheErr != nil { 106 + slog.Warn("Failed to cache negative README result", "error", cacheErr) 107 + } 108 + return "", nil 109 + } 110 + // Other error (network, etc.) - don't cache, allow retry 111 + return "", err 112 + } 113 + } else { 114 + // Other error (network, etc.) - don't cache, allow retry 115 + return "", err 116 + } 117 + } 118 + 119 + // Store successful result in cache 120 + if err := c.storeInDB(cacheKey, html); err != nil { 121 + slog.Warn("Failed to cache README from source", "error", err) 58 122 } 59 123 60 124 return html, nil
+245 -2
pkg/appview/readme/cache_test.go
··· 1 1 package readme 2 2 3 - import "testing" 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "testing" 8 + "time" 9 + 10 + _ "github.com/mattn/go-sqlite3" 11 + ) 4 12 5 13 func TestCache_Struct(t *testing.T) { 6 14 // Simple struct test ··· 10 18 } 11 19 } 12 20 13 - // TODO: Add cache operation tests 21 + func setupTestDB(t *testing.T) *sql.DB { 22 + t.Helper() 23 + db, err := sql.Open("sqlite3", ":memory:") 24 + if err != nil { 25 + t.Fatalf("Failed to open database: %v", err) 26 + } 27 + 28 + // Create the readme_cache table 29 + _, err = db.Exec(` 30 + CREATE TABLE readme_cache ( 31 + url TEXT PRIMARY KEY, 32 + html TEXT NOT NULL, 33 + fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 34 + ) 35 + `) 36 + if err != nil { 37 + t.Fatalf("Failed to create table: %v", err) 38 + } 39 + 40 + return db 41 + } 42 + 43 + func TestGetFromSource_UnsupportedPlatform(t *testing.T) { 44 + db := setupTestDB(t) 45 + defer db.Close() 46 + 47 + cache := NewCache(db, time.Hour) 48 + ctx := context.Background() 49 + 50 + // Unsupported platform should return empty, no error 51 + html, err := cache.GetFromSource(ctx, "https://bitbucket.org/user/repo") 52 + if err != nil { 53 + t.Errorf("Expected no error for unsupported platform, got: %v", err) 54 + } 55 + if html != "" { 56 + t.Errorf("Expected empty string for unsupported platform, got: %q", html) 57 + } 58 + } 59 + 60 + func TestGetFromSource_CacheHit(t *testing.T) { 61 + db := setupTestDB(t) 62 + defer db.Close() 63 + 64 + cache := NewCache(db, time.Hour) 65 + sourceURL := "https://github.com/test/repo" 66 + cacheKey := sourceCachePrefix + sourceURL 67 + expectedHTML := "<h1>Cached Content</h1>" 68 + 69 + // Pre-populate cache 70 + _, err := db.Exec(` 71 + INSERT INTO readme_cache (url, html, fetched_at) 72 + VALUES (?, ?, ?) 73 + `, cacheKey, expectedHTML, time.Now()) 74 + if err != nil { 75 + t.Fatalf("Failed to insert cache: %v", err) 76 + } 77 + 78 + ctx := context.Background() 79 + html, err := cache.GetFromSource(ctx, sourceURL) 80 + if err != nil { 81 + t.Errorf("Expected no error, got: %v", err) 82 + } 83 + if html != expectedHTML { 84 + t.Errorf("Expected %q, got %q", expectedHTML, html) 85 + } 86 + } 87 + 88 + func TestGetFromSource_CacheExpired(t *testing.T) { 89 + db := setupTestDB(t) 90 + defer db.Close() 91 + 92 + cache := NewCache(db, time.Millisecond) // Very short TTL 93 + sourceURL := "https://github.com/test/repo" 94 + cacheKey := sourceCachePrefix + sourceURL 95 + oldHTML := "<h1>Old Content</h1>" 96 + 97 + // Pre-populate cache with old timestamp 98 + _, err := db.Exec(` 99 + INSERT INTO readme_cache (url, html, fetched_at) 100 + VALUES (?, ?, ?) 101 + `, cacheKey, oldHTML, time.Now().Add(-time.Hour)) 102 + if err != nil { 103 + t.Fatalf("Failed to insert cache: %v", err) 104 + } 105 + 106 + ctx := context.Background() 107 + 108 + // With expired cache and no network (GitHub won't respond), we expect an error 109 + // but the function should try to fetch 110 + _, err = cache.GetFromSource(ctx, sourceURL) 111 + // We expect an error because we can't actually fetch from GitHub in tests 112 + // The important thing is that it tried to fetch (didn't return cached content) 113 + if err == nil { 114 + t.Log("Note: GetFromSource returned no error - cache was expired and fetch was attempted") 115 + } 116 + } 117 + 118 + func TestGetFromSource_NegativeCache(t *testing.T) { 119 + db := setupTestDB(t) 120 + defer db.Close() 121 + 122 + cache := NewCache(db, time.Hour) 123 + sourceURL := "https://github.com/test/repo" 124 + cacheKey := sourceCachePrefix + sourceURL 125 + 126 + // Pre-populate cache with empty string (negative cache) 127 + _, err := db.Exec(` 128 + INSERT INTO readme_cache (url, html, fetched_at) 129 + VALUES (?, ?, ?) 130 + `, cacheKey, "", time.Now()) 131 + if err != nil { 132 + t.Fatalf("Failed to insert cache: %v", err) 133 + } 134 + 135 + ctx := context.Background() 136 + html, err := cache.GetFromSource(ctx, sourceURL) 137 + if err != nil { 138 + t.Errorf("Expected no error for negative cache hit, got: %v", err) 139 + } 140 + if html != "" { 141 + t.Errorf("Expected empty string for negative cache hit, got: %q", html) 142 + } 143 + } 144 + 145 + func TestGetFromSource_NegativeCacheExpired(t *testing.T) { 146 + db := setupTestDB(t) 147 + defer db.Close() 148 + 149 + cache := NewCache(db, time.Hour) 150 + sourceURL := "https://github.com/test/repo" 151 + cacheKey := sourceCachePrefix + sourceURL 152 + 153 + // Pre-populate cache with expired negative cache (older than negativeCacheTTL) 154 + _, err := db.Exec(` 155 + INSERT INTO readme_cache (url, html, fetched_at) 156 + VALUES (?, ?, ?) 157 + `, cacheKey, "", time.Now().Add(-30*time.Minute)) // 30 min ago, negative TTL is 15 min 158 + if err != nil { 159 + t.Fatalf("Failed to insert cache: %v", err) 160 + } 161 + 162 + ctx := context.Background() 163 + 164 + // With expired negative cache, it should try to fetch again 165 + _, err = cache.GetFromSource(ctx, sourceURL) 166 + // We expect an error because we can't actually fetch from GitHub 167 + // The important thing is that it tried (didn't return empty from expired negative cache) 168 + if err == nil { 169 + t.Log("Note: GetFromSource attempted refetch after negative cache expired") 170 + } 171 + } 172 + 173 + func TestGetFromSource_EmptyURL(t *testing.T) { 174 + db := setupTestDB(t) 175 + defer db.Close() 176 + 177 + cache := NewCache(db, time.Hour) 178 + ctx := context.Background() 179 + 180 + html, err := cache.GetFromSource(ctx, "") 181 + if err != nil { 182 + t.Errorf("Expected no error for empty URL, got: %v", err) 183 + } 184 + if html != "" { 185 + t.Errorf("Expected empty string for empty URL, got: %q", html) 186 + } 187 + } 188 + 189 + func TestGetFromSource_UnsupportedPlatforms(t *testing.T) { 190 + db := setupTestDB(t) 191 + defer db.Close() 192 + 193 + cache := NewCache(db, time.Hour) 194 + ctx := context.Background() 195 + 196 + unsupportedURLs := []string{ 197 + "https://bitbucket.org/user/repo", 198 + "https://sourcehut.org/user/repo", 199 + "https://codeberg.org/user/repo", 200 + "ftp://github.com/user/repo", 201 + "not-a-url", 202 + } 203 + 204 + for _, url := range unsupportedURLs { 205 + html, err := cache.GetFromSource(ctx, url) 206 + if err != nil { 207 + t.Errorf("Expected no error for unsupported URL %q, got: %v", url, err) 208 + } 209 + if html != "" { 210 + t.Errorf("Expected empty string for unsupported URL %q, got: %q", url, html) 211 + } 212 + } 213 + } 214 + 215 + func TestIs404(t *testing.T) { 216 + tests := []struct { 217 + name string 218 + err error 219 + want bool 220 + }{ 221 + { 222 + name: "nil error", 223 + err: nil, 224 + want: false, 225 + }, 226 + { 227 + name: "404 error", 228 + err: fmt.Errorf("unexpected status code: 404"), 229 + want: true, 230 + }, 231 + { 232 + name: "404 error with context", 233 + err: fmt.Errorf("failed to fetch: unexpected status code: 404"), 234 + want: true, 235 + }, 236 + { 237 + name: "500 error", 238 + err: fmt.Errorf("unexpected status code: 500"), 239 + want: false, 240 + }, 241 + { 242 + name: "network error", 243 + err: fmt.Errorf("connection refused"), 244 + want: false, 245 + }, 246 + } 247 + 248 + for _, tt := range tests { 249 + t.Run(tt.name, func(t *testing.T) { 250 + got := Is404(tt.err) 251 + if got != tt.want { 252 + t.Errorf("Is404(%v) = %v, want %v", tt.err, got, tt.want) 253 + } 254 + }) 255 + } 256 + }
+5
pkg/appview/readme/fetcher.go
··· 180 180 return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, path) 181 181 } 182 182 183 + // Is404 returns true if the error indicates a 404 Not Found response 184 + func Is404(err error) bool { 185 + return err != nil && strings.Contains(err.Error(), "unexpected status code: 404") 186 + } 187 + 183 188 // rewriteRelativeURLs converts relative URLs to absolute URLs 184 189 func rewriteRelativeURLs(html, baseURL string) string { 185 190 if baseURL == "" {
+103
pkg/appview/readme/source.go
··· 1 + package readme 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + "strings" 7 + ) 8 + 9 + // Platform represents a supported Git hosting platform 10 + type Platform string 11 + 12 + const ( 13 + PlatformGitHub Platform = "github" 14 + PlatformGitLab Platform = "gitlab" 15 + PlatformTangled Platform = "tangled" 16 + ) 17 + 18 + // ParseSourceURL extracts platform, user, and repo from a source repository URL. 19 + // Returns ok=false if the URL is not a recognized pattern. 20 + func ParseSourceURL(sourceURL string) (platform Platform, user, repo string, ok bool) { 21 + if sourceURL == "" { 22 + return "", "", "", false 23 + } 24 + 25 + parsed, err := url.Parse(sourceURL) 26 + if err != nil { 27 + return "", "", "", false 28 + } 29 + 30 + // Normalize: remove trailing slash and .git suffix 31 + path := strings.TrimSuffix(parsed.Path, "/") 32 + path = strings.TrimSuffix(path, ".git") 33 + path = strings.TrimPrefix(path, "/") 34 + 35 + if path == "" { 36 + return "", "", "", false 37 + } 38 + 39 + host := strings.ToLower(parsed.Host) 40 + 41 + switch { 42 + case host == "github.com": 43 + // GitHub: github.com/{user}/{repo} 44 + parts := strings.SplitN(path, "/", 3) 45 + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { 46 + return "", "", "", false 47 + } 48 + return PlatformGitHub, parts[0], parts[1], true 49 + 50 + case host == "gitlab.com": 51 + // GitLab: gitlab.com/{user}/{repo} or gitlab.com/{group}/{subgroup}/{repo} 52 + // For nested groups, user = everything except last part, repo = last part 53 + lastSlash := strings.LastIndex(path, "/") 54 + if lastSlash == -1 || lastSlash == 0 { 55 + return "", "", "", false 56 + } 57 + user = path[:lastSlash] 58 + repo = path[lastSlash+1:] 59 + if user == "" || repo == "" { 60 + return "", "", "", false 61 + } 62 + return PlatformGitLab, user, repo, true 63 + 64 + case host == "tangled.org" || host == "tangled.sh": 65 + // Tangled: tangled.org/{user}/{repo} or tangled.sh/@{user}/{repo} (legacy) 66 + // Strip leading @ from user if present 67 + path = strings.TrimPrefix(path, "@") 68 + parts := strings.SplitN(path, "/", 3) 69 + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { 70 + return "", "", "", false 71 + } 72 + return PlatformTangled, parts[0], parts[1], true 73 + 74 + default: 75 + return "", "", "", false 76 + } 77 + } 78 + 79 + // DeriveReadmeURL converts a source repository URL to a raw README URL. 80 + // Returns empty string if platform is not supported. 81 + func DeriveReadmeURL(sourceURL, branch string) string { 82 + platform, user, repo, ok := ParseSourceURL(sourceURL) 83 + if !ok { 84 + return "" 85 + } 86 + 87 + switch platform { 88 + case PlatformGitHub: 89 + // https://raw.githubusercontent.com/{user}/{repo}/refs/heads/{branch}/README.md 90 + return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/refs/heads/%s/README.md", user, repo, branch) 91 + 92 + case PlatformGitLab: 93 + // https://gitlab.com/{user}/{repo}/-/raw/{branch}/README.md 94 + return fmt.Sprintf("https://gitlab.com/%s/%s/-/raw/%s/README.md", user, repo, branch) 95 + 96 + case PlatformTangled: 97 + // https://tangled.org/{user}/{repo}/raw/{branch}/README.md 98 + return fmt.Sprintf("https://tangled.org/%s/%s/raw/%s/README.md", user, repo, branch) 99 + 100 + default: 101 + return "" 102 + } 103 + }
+241
pkg/appview/readme/source_test.go
··· 1 + package readme 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestParseSourceURL(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + sourceURL string 11 + wantPlatform Platform 12 + wantUser string 13 + wantRepo string 14 + wantOK bool 15 + }{ 16 + // GitHub 17 + { 18 + name: "github standard", 19 + sourceURL: "https://github.com/bigmoves/quickslice", 20 + wantPlatform: PlatformGitHub, 21 + wantUser: "bigmoves", 22 + wantRepo: "quickslice", 23 + wantOK: true, 24 + }, 25 + { 26 + name: "github with .git suffix", 27 + sourceURL: "https://github.com/user/repo.git", 28 + wantPlatform: PlatformGitHub, 29 + wantUser: "user", 30 + wantRepo: "repo", 31 + wantOK: true, 32 + }, 33 + { 34 + name: "github with trailing slash", 35 + sourceURL: "https://github.com/user/repo/", 36 + wantPlatform: PlatformGitHub, 37 + wantUser: "user", 38 + wantRepo: "repo", 39 + wantOK: true, 40 + }, 41 + { 42 + name: "github with subpath (ignored)", 43 + sourceURL: "https://github.com/user/repo/tree/main", 44 + wantPlatform: PlatformGitHub, 45 + wantUser: "user", 46 + wantRepo: "repo", 47 + wantOK: true, 48 + }, 49 + { 50 + name: "github user only", 51 + sourceURL: "https://github.com/user", 52 + wantOK: false, 53 + }, 54 + 55 + // GitLab 56 + { 57 + name: "gitlab standard", 58 + sourceURL: "https://gitlab.com/user/repo", 59 + wantPlatform: PlatformGitLab, 60 + wantUser: "user", 61 + wantRepo: "repo", 62 + wantOK: true, 63 + }, 64 + { 65 + name: "gitlab nested groups", 66 + sourceURL: "https://gitlab.com/group/subgroup/repo", 67 + wantPlatform: PlatformGitLab, 68 + wantUser: "group/subgroup", 69 + wantRepo: "repo", 70 + wantOK: true, 71 + }, 72 + { 73 + name: "gitlab deep nested groups", 74 + sourceURL: "https://gitlab.com/a/b/c/d/repo", 75 + wantPlatform: PlatformGitLab, 76 + wantUser: "a/b/c/d", 77 + wantRepo: "repo", 78 + wantOK: true, 79 + }, 80 + { 81 + name: "gitlab with .git suffix", 82 + sourceURL: "https://gitlab.com/user/repo.git", 83 + wantPlatform: PlatformGitLab, 84 + wantUser: "user", 85 + wantRepo: "repo", 86 + wantOK: true, 87 + }, 88 + 89 + // Tangled 90 + { 91 + name: "tangled standard", 92 + sourceURL: "https://tangled.org/evan.jarrett.net/at-container-registry", 93 + wantPlatform: PlatformTangled, 94 + wantUser: "evan.jarrett.net", 95 + wantRepo: "at-container-registry", 96 + wantOK: true, 97 + }, 98 + { 99 + name: "tangled with legacy @ prefix", 100 + sourceURL: "https://tangled.org/@evan.jarrett.net/at-container-registry", 101 + wantPlatform: PlatformTangled, 102 + wantUser: "evan.jarrett.net", 103 + wantRepo: "at-container-registry", 104 + wantOK: true, 105 + }, 106 + { 107 + name: "tangled.sh domain", 108 + sourceURL: "https://tangled.sh/user/repo", 109 + wantPlatform: PlatformTangled, 110 + wantUser: "user", 111 + wantRepo: "repo", 112 + wantOK: true, 113 + }, 114 + { 115 + name: "tangled with trailing slash", 116 + sourceURL: "https://tangled.org/user/repo/", 117 + wantPlatform: PlatformTangled, 118 + wantUser: "user", 119 + wantRepo: "repo", 120 + wantOK: true, 121 + }, 122 + 123 + // Unsupported / Invalid 124 + { 125 + name: "unsupported platform", 126 + sourceURL: "https://bitbucket.org/user/repo", 127 + wantOK: false, 128 + }, 129 + { 130 + name: "empty url", 131 + sourceURL: "", 132 + wantOK: false, 133 + }, 134 + { 135 + name: "invalid url", 136 + sourceURL: "not-a-url", 137 + wantOK: false, 138 + }, 139 + { 140 + name: "just host", 141 + sourceURL: "https://github.com", 142 + wantOK: false, 143 + }, 144 + } 145 + 146 + for _, tt := range tests { 147 + t.Run(tt.name, func(t *testing.T) { 148 + platform, user, repo, ok := ParseSourceURL(tt.sourceURL) 149 + if ok != tt.wantOK { 150 + t.Errorf("ParseSourceURL(%q) ok = %v, want %v", tt.sourceURL, ok, tt.wantOK) 151 + return 152 + } 153 + if !tt.wantOK { 154 + return 155 + } 156 + if platform != tt.wantPlatform { 157 + t.Errorf("ParseSourceURL(%q) platform = %v, want %v", tt.sourceURL, platform, tt.wantPlatform) 158 + } 159 + if user != tt.wantUser { 160 + t.Errorf("ParseSourceURL(%q) user = %q, want %q", tt.sourceURL, user, tt.wantUser) 161 + } 162 + if repo != tt.wantRepo { 163 + t.Errorf("ParseSourceURL(%q) repo = %q, want %q", tt.sourceURL, repo, tt.wantRepo) 164 + } 165 + }) 166 + } 167 + } 168 + 169 + func TestDeriveReadmeURL(t *testing.T) { 170 + tests := []struct { 171 + name string 172 + sourceURL string 173 + branch string 174 + want string 175 + }{ 176 + // GitHub 177 + { 178 + name: "github main", 179 + sourceURL: "https://github.com/bigmoves/quickslice", 180 + branch: "main", 181 + want: "https://raw.githubusercontent.com/bigmoves/quickslice/refs/heads/main/README.md", 182 + }, 183 + { 184 + name: "github master", 185 + sourceURL: "https://github.com/user/repo", 186 + branch: "master", 187 + want: "https://raw.githubusercontent.com/user/repo/refs/heads/master/README.md", 188 + }, 189 + 190 + // GitLab 191 + { 192 + name: "gitlab main", 193 + sourceURL: "https://gitlab.com/user/repo", 194 + branch: "main", 195 + want: "https://gitlab.com/user/repo/-/raw/main/README.md", 196 + }, 197 + { 198 + name: "gitlab nested groups", 199 + sourceURL: "https://gitlab.com/group/subgroup/repo", 200 + branch: "main", 201 + want: "https://gitlab.com/group/subgroup/repo/-/raw/main/README.md", 202 + }, 203 + 204 + // Tangled 205 + { 206 + name: "tangled main", 207 + sourceURL: "https://tangled.org/evan.jarrett.net/at-container-registry", 208 + branch: "main", 209 + want: "https://tangled.org/evan.jarrett.net/at-container-registry/raw/main/README.md", 210 + }, 211 + { 212 + name: "tangled legacy @ prefix", 213 + sourceURL: "https://tangled.org/@user/repo", 214 + branch: "main", 215 + want: "https://tangled.org/user/repo/raw/main/README.md", 216 + }, 217 + 218 + // Unsupported 219 + { 220 + name: "unsupported platform", 221 + sourceURL: "https://bitbucket.org/user/repo", 222 + branch: "main", 223 + want: "", 224 + }, 225 + { 226 + name: "empty url", 227 + sourceURL: "", 228 + branch: "main", 229 + want: "", 230 + }, 231 + } 232 + 233 + for _, tt := range tests { 234 + t.Run(tt.name, func(t *testing.T) { 235 + got := DeriveReadmeURL(tt.sourceURL, tt.branch) 236 + if got != tt.want { 237 + t.Errorf("DeriveReadmeURL(%q, %q) = %q, want %q", tt.sourceURL, tt.branch, got, tt.want) 238 + } 239 + }) 240 + } 241 + }