A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
73
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 + }