···11+description: Add repo_pages table and remove readme_cache
22+query: |
33+ -- Create repo_pages table for storing repository page metadata
44+ -- This replaces readme_cache with PDS-synced data
55+ CREATE TABLE IF NOT EXISTS repo_pages (
66+ did TEXT NOT NULL,
77+ repository TEXT NOT NULL,
88+ description TEXT,
99+ avatar_cid TEXT,
1010+ created_at TIMESTAMP NOT NULL,
1111+ updated_at TIMESTAMP NOT NULL,
1212+ PRIMARY KEY(did, repository),
1313+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
1414+ );
1515+ CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did);
1616+1717+ -- Drop readme_cache table (no longer needed)
1818+ DROP TABLE IF EXISTS readme_cache;
+68
pkg/appview/db/queries.go
···1691169116921692 return featured, nil
16931693}
16941694+16951695+// RepoPage represents a repository page record cached from PDS
16961696+type RepoPage struct {
16971697+ DID string
16981698+ Repository string
16991699+ Description string
17001700+ AvatarCID string
17011701+ CreatedAt time.Time
17021702+ UpdatedAt time.Time
17031703+}
17041704+17051705+// UpsertRepoPage inserts or updates a repo page record
17061706+func UpsertRepoPage(db *sql.DB, did, repository, description, avatarCID string, createdAt, updatedAt time.Time) error {
17071707+ _, err := db.Exec(`
17081708+ INSERT INTO repo_pages (did, repository, description, avatar_cid, created_at, updated_at)
17091709+ VALUES (?, ?, ?, ?, ?, ?)
17101710+ ON CONFLICT(did, repository) DO UPDATE SET
17111711+ description = excluded.description,
17121712+ avatar_cid = excluded.avatar_cid,
17131713+ updated_at = excluded.updated_at
17141714+ `, did, repository, description, avatarCID, createdAt, updatedAt)
17151715+ return err
17161716+}
17171717+17181718+// GetRepoPage retrieves a repo page record
17191719+func GetRepoPage(db *sql.DB, did, repository string) (*RepoPage, error) {
17201720+ var rp RepoPage
17211721+ err := db.QueryRow(`
17221722+ SELECT did, repository, description, avatar_cid, created_at, updated_at
17231723+ FROM repo_pages
17241724+ WHERE did = ? AND repository = ?
17251725+ `, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt)
17261726+ if err != nil {
17271727+ return nil, err
17281728+ }
17291729+ return &rp, nil
17301730+}
17311731+17321732+// DeleteRepoPage deletes a repo page record
17331733+func DeleteRepoPage(db *sql.DB, did, repository string) error {
17341734+ _, err := db.Exec(`
17351735+ DELETE FROM repo_pages WHERE did = ? AND repository = ?
17361736+ `, did, repository)
17371737+ return err
17381738+}
17391739+17401740+// GetRepoPagesByDID returns all repo pages for a DID
17411741+func GetRepoPagesByDID(db *sql.DB, did string) ([]RepoPage, error) {
17421742+ rows, err := db.Query(`
17431743+ SELECT did, repository, description, avatar_cid, created_at, updated_at
17441744+ FROM repo_pages
17451745+ WHERE did = ?
17461746+ `, did)
17471747+ if err != nil {
17481748+ return nil, err
17491749+ }
17501750+ defer rows.Close()
17511751+17521752+ var pages []RepoPage
17531753+ for rows.Next() {
17541754+ var rp RepoPage
17551755+ if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt); err != nil {
17561756+ return nil, err
17571757+ }
17581758+ pages = append(pages, rp)
17591759+ }
17601760+ return pages, rows.Err()
17611761+}
+10-5
pkg/appview/db/schema.sql
···205205);
206206CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
207207208208-CREATE TABLE IF NOT EXISTS readme_cache (
209209- url TEXT PRIMARY KEY,
210210- html TEXT NOT NULL,
211211- fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
208208+CREATE TABLE IF NOT EXISTS repo_pages (
209209+ did TEXT NOT NULL,
210210+ repository TEXT NOT NULL,
211211+ description TEXT,
212212+ avatar_cid TEXT,
213213+ created_at TIMESTAMP NOT NULL,
214214+ updated_at TIMESTAMP NOT NULL,
215215+ PRIMARY KEY(did, repository),
216216+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
212217);
213213-CREATE INDEX IF NOT EXISTS idx_readme_cache_fetched ON readme_cache(fetched_at);
218218+CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did);
+27-14
pkg/appview/handlers/repository.go
···2727 Directory identity.Directory
2828 Refresher *oauth.Refresher
2929 HealthChecker *holdhealth.Checker
3030- ReadmeCache *readme.Cache
3030+ ReadmeFetcher *readme.Fetcher // For rendering repo page descriptions
3131}
32323333func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···190190 isOwner = (user.DID == owner.DID)
191191 }
192192193193- // Fetch README content if available
193193+ // Fetch README content from repo page record or annotations
194194 var readmeHTML template.HTML
195195- if h.ReadmeCache != nil {
196196- ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
197197- defer cancel()
198195199199- if repo.ReadmeURL != "" {
200200- // Explicit io.atcr.readme takes priority
201201- html, err := h.ReadmeCache.Get(ctx, repo.ReadmeURL)
196196+ // Try repo page record from database (synced from PDS via Jetstream)
197197+ repoPage, err := db.GetRepoPage(h.DB, owner.DID, repository)
198198+ if err == nil && repoPage != nil && repoPage.Description != "" {
199199+ // Use repo page data
200200+ if h.ReadmeFetcher != nil {
201201+ html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description))
202202 if err != nil {
203203- slog.Warn("Failed to fetch README", "url", repo.ReadmeURL, "error", err)
203203+ slog.Warn("Failed to render repo page description", "error", err)
204204 } else {
205205 readmeHTML = template.HTML(html)
206206 }
207207- } else if repo.SourceURL != "" {
208208- // Derive README from org.opencontainers.image.source
209209- html, err := h.ReadmeCache.GetFromSource(ctx, repo.SourceURL)
207207+ }
208208+ if repoPage.AvatarCID != "" {
209209+ repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID)
210210+ }
211211+ } else if h.ReadmeFetcher != nil {
212212+ // Fall back to fetching from URL annotations
213213+ readmeURL := repo.ReadmeURL
214214+ if readmeURL == "" && repo.SourceURL != "" {
215215+ // Try to derive README URL from source URL
216216+ readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "main")
217217+ if readmeURL == "" {
218218+ readmeURL = readme.DeriveReadmeURL(repo.SourceURL, "master")
219219+ }
220220+ }
221221+ if readmeURL != "" {
222222+ html, err := h.ReadmeFetcher.FetchAndRender(r.Context(), readmeURL)
210223 if err != nil {
211211- slog.Debug("Failed to derive README from source", "url", repo.SourceURL, "error", err)
212212- } else if html != "" {
224224+ slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", err)
225225+ } else {
213226 readmeHTML = template.HTML(html)
214227 }
215228 }
+4
pkg/appview/jetstream/backfill.go
···6767 atproto.TagCollection, // io.atcr.tag
6868 atproto.StarCollection, // io.atcr.sailor.star
6969 atproto.SailorProfileCollection, // io.atcr.sailor.profile
7070+ atproto.RepoPageCollection, // io.atcr.repo.page
7071 }
71727273 for _, collection := range collections {
···282283 return b.processor.ProcessStar(context.Background(), did, record.Value)
283284 case atproto.SailorProfileCollection:
284285 return b.processor.ProcessSailorProfile(ctx, did, record.Value, b.queryCaptainRecordWrapper)
286286+ case atproto.RepoPageCollection:
287287+ // rkey is extracted from the record URI, but for repo pages we use Repository field
288288+ return b.processor.ProcessRepoPage(ctx, did, record.URI, record.Value, false)
285289 default:
286290 return fmt.Errorf("unsupported collection: %s", collection)
287291 }
+24
pkg/appview/jetstream/processor.go
···299299 return nil
300300}
301301302302+// ProcessRepoPage processes a repository page record
303303+// This is called when Jetstream receives a repo page create/update event
304304+func (p *Processor) ProcessRepoPage(ctx context.Context, did string, rkey string, recordData []byte, isDelete bool) error {
305305+ if isDelete {
306306+ // Delete the repo page from our cache
307307+ return db.DeleteRepoPage(p.db, did, rkey)
308308+ }
309309+310310+ // Unmarshal repo page record
311311+ var pageRecord atproto.RepoPageRecord
312312+ if err := json.Unmarshal(recordData, &pageRecord); err != nil {
313313+ return fmt.Errorf("failed to unmarshal repo page: %w", err)
314314+ }
315315+316316+ // Extract avatar CID if present
317317+ avatarCID := ""
318318+ if pageRecord.Avatar != nil && pageRecord.Avatar.Ref.Link != "" {
319319+ avatarCID = pageRecord.Avatar.Ref.Link
320320+ }
321321+322322+ // Upsert to database
323323+ return db.UpsertRepoPage(p.db, did, pageRecord.Repository, pageRecord.Description, avatarCID, pageRecord.CreatedAt, pageRecord.UpdatedAt)
324324+}
325325+302326// ProcessIdentity handles identity change events (handle updates)
303327// This is called when Jetstream receives an identity event indicating a handle change.
304328// The identity cache is invalidated to ensure the next lookup uses the new handle,
+38
pkg/appview/jetstream/worker.go
···312312 case atproto.StarCollection:
313313 slog.Info("Jetstream processing star event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
314314 return w.processStar(commit)
315315+ case atproto.RepoPageCollection:
316316+ slog.Info("Jetstream processing repo page event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
317317+ return w.processRepoPage(commit)
315318 default:
316319 // Ignore other collections
317320 return nil
···434437435438 // Use shared processor for DB operations
436439 return w.processor.ProcessStar(context.Background(), commit.DID, recordBytes)
440440+}
441441+442442+// processRepoPage processes a repo page commit event
443443+func (w *Worker) processRepoPage(commit *CommitEvent) error {
444444+ // Resolve and upsert user with handle/PDS endpoint
445445+ if err := w.processor.EnsureUser(context.Background(), commit.DID); err != nil {
446446+ return fmt.Errorf("failed to ensure user: %w", err)
447447+ }
448448+449449+ isDelete := commit.Operation == "delete"
450450+451451+ if isDelete {
452452+ // Delete - rkey is the repository name
453453+ slog.Info("Jetstream deleting repo page", "did", commit.DID, "repository", commit.RKey)
454454+ if err := w.processor.ProcessRepoPage(context.Background(), commit.DID, commit.RKey, nil, true); err != nil {
455455+ slog.Error("Jetstream ERROR deleting repo page", "error", err)
456456+ return err
457457+ }
458458+ slog.Info("Jetstream successfully deleted repo page", "did", commit.DID, "repository", commit.RKey)
459459+ return nil
460460+ }
461461+462462+ // Parse repo page record
463463+ if commit.Record == nil {
464464+ return nil
465465+ }
466466+467467+ // Marshal map to bytes for processing
468468+ recordBytes, err := json.Marshal(commit.Record)
469469+ if err != nil {
470470+ return fmt.Errorf("failed to marshal record: %w", err)
471471+ }
472472+473473+ // Use shared processor for DB operations
474474+ return w.processor.ProcessRepoPage(context.Background(), commit.DID, commit.RKey, recordBytes, false)
437475}
438476439477// processIdentity processes an identity event (handle change)
+3-13
pkg/appview/middleware/registry.go
···170170// These are set by main.go during startup and copied into NamespaceResolver instances.
171171// After initialization, request handling uses the NamespaceResolver's instance fields.
172172var (
173173- globalRefresher *oauth.Refresher
174174- globalDatabase storage.DatabaseMetrics
175175- globalAuthorizer auth.HoldAuthorizer
176176- globalReadmeCache storage.ReadmeCache
173173+ globalRefresher *oauth.Refresher
174174+ globalDatabase storage.DatabaseMetrics
175175+ globalAuthorizer auth.HoldAuthorizer
177176)
178177179178// SetGlobalRefresher sets the OAuth refresher instance during initialization
···194193 globalAuthorizer = authorizer
195194}
196195197197-// SetGlobalReadmeCache sets the readme cache instance during initialization
198198-// Must be called before the registry starts serving requests
199199-func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) {
200200- globalReadmeCache = readmeCache
201201-}
202202-203196func init() {
204197 // Register the name resolution middleware
205198 registrymw.Register("atproto-resolver", initATProtoResolver)
···214207 refresher *oauth.Refresher // OAuth session manager (copied from global on init)
215208 database storage.DatabaseMetrics // Metrics database (copied from global on init)
216209 authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
217217- readmeCache storage.ReadmeCache // README cache (copied from global on init)
218210 validationCache *validationCache // Request-level service token cache
219211}
220212···249241 refresher: globalRefresher,
250242 database: globalDatabase,
251243 authorizer: globalAuthorizer,
252252- readmeCache: globalReadmeCache,
253244 validationCache: newValidationCache(),
254245 }, nil
255246}
···467458 Database: nr.database,
468459 Authorizer: nr.authorizer,
469460 Refresher: nr.refresher,
470470- ReadmeCache: nr.readmeCache,
471461 }
472462473463 return storage.NewRoutingRepository(repo, registryCtx), nil
-5
pkg/appview/middleware/registry_test.go
···6767 // If we get here without panic, test passes
6868}
69697070-func TestSetGlobalReadmeCache(t *testing.T) {
7171- SetGlobalReadmeCache(nil)
7272- // If we get here without panic, test passes
7373-}
7474-7570// TestInitATProtoResolver tests the initialization function
7671func TestInitATProtoResolver(t *testing.T) {
7772 ctx := context.Background()
-175
pkg/appview/readme/cache.go
···11-// Package readme provides README fetching, rendering, and caching functionality
22-// for container repositories. It fetches markdown content from URLs, renders it
33-// to sanitized HTML using GitHub-flavored markdown, and caches the results in
44-// a database with configurable TTL.
55-package readme
66-77-import (
88- "context"
99- "database/sql"
1010- "log/slog"
1111- "time"
1212-)
1313-1414-const (
1515- // negativeCacheTTL is the TTL for negative cache entries (no README found)
1616- negativeCacheTTL = 15 * time.Minute
1717- // sourceCachePrefix is the prefix for source-derived cache keys
1818- sourceCachePrefix = "source:"
1919-)
2020-2121-// Cache stores rendered README HTML in the database
2222-type Cache struct {
2323- db *sql.DB
2424- fetcher *Fetcher
2525- ttl time.Duration
2626-}
2727-2828-// NewCache creates a new README cache
2929-func NewCache(db *sql.DB, ttl time.Duration) *Cache {
3030- if ttl == 0 {
3131- ttl = 1 * time.Hour // Default TTL
3232- }
3333- return &Cache{
3434- db: db,
3535- fetcher: NewFetcher(),
3636- ttl: ttl,
3737- }
3838-}
3939-4040-// Get retrieves a README from cache or fetches it
4141-func (c *Cache) Get(ctx context.Context, readmeURL string) (string, error) {
4242- // Try to get from cache
4343- html, fetchedAt, err := c.getFromDB(readmeURL)
4444- if err == nil {
4545- // Check if cache is still valid
4646- if time.Since(fetchedAt) < c.ttl {
4747- return html, nil
4848- }
4949- }
5050-5151- // Cache miss or expired, fetch fresh content
5252- html, err = c.fetcher.FetchAndRender(ctx, readmeURL)
5353- if err != nil {
5454- // If fetch fails but we have stale cache, return it
5555- if html != "" {
5656- return html, nil
5757- }
5858- return "", err
5959- }
6060-6161- // Store in cache
6262- if err := c.storeInDB(readmeURL, html); err != nil {
6363- // Log error but don't fail - we have the content
6464- slog.Warn("Failed to cache README", "error", err)
6565- }
6666-6767- return html, nil
6868-}
6969-7070-// GetFromSource fetches a README by deriving the URL from a source repository URL.
7171-// It tries main branch first, then falls back to master if 404.
7272-// Returns empty string if no README found (cached as negative result with shorter TTL).
7373-func (c *Cache) GetFromSource(ctx context.Context, sourceURL string) (string, error) {
7474- cacheKey := sourceCachePrefix + sourceURL
7575-7676- // Try to get from cache
7777- html, fetchedAt, err := c.getFromDB(cacheKey)
7878- if err == nil {
7979- // Determine TTL based on whether this is a negative cache entry
8080- ttl := c.ttl
8181- if html == "" {
8282- ttl = negativeCacheTTL
8383- }
8484- if time.Since(fetchedAt) < ttl {
8585- return html, nil
8686- }
8787- }
8888-8989- // Derive README URL and fetch
9090- // Try main branch first
9191- readmeURL := DeriveReadmeURL(sourceURL, "main")
9292- if readmeURL == "" {
9393- return "", nil // Unsupported platform, don't cache
9494- }
9595-9696- html, err = c.fetcher.FetchAndRender(ctx, readmeURL)
9797- if err != nil {
9898- if Is404(err) {
9999- // Try master branch
100100- readmeURL = DeriveReadmeURL(sourceURL, "master")
101101- html, err = c.fetcher.FetchAndRender(ctx, readmeURL)
102102- if err != nil {
103103- if Is404(err) {
104104- // No README on either branch - cache negative result
105105- if cacheErr := c.storeInDB(cacheKey, ""); cacheErr != nil {
106106- slog.Warn("Failed to cache negative README result", "error", cacheErr)
107107- }
108108- return "", nil
109109- }
110110- // Other error (network, etc.) - don't cache, allow retry
111111- return "", err
112112- }
113113- } else {
114114- // Other error (network, etc.) - don't cache, allow retry
115115- return "", err
116116- }
117117- }
118118-119119- // Store successful result in cache
120120- if err := c.storeInDB(cacheKey, html); err != nil {
121121- slog.Warn("Failed to cache README from source", "error", err)
122122- }
123123-124124- return html, nil
125125-}
126126-127127-// getFromDB retrieves cached README from database
128128-func (c *Cache) getFromDB(readmeURL string) (string, time.Time, error) {
129129- var html string
130130- var fetchedAt time.Time
131131-132132- err := c.db.QueryRow(`
133133- SELECT html, fetched_at
134134- FROM readme_cache
135135- WHERE url = ?
136136- `, readmeURL).Scan(&html, &fetchedAt)
137137-138138- if err != nil {
139139- return "", time.Time{}, err
140140- }
141141-142142- return html, fetchedAt, nil
143143-}
144144-145145-// storeInDB stores rendered README in database
146146-func (c *Cache) storeInDB(readmeURL, html string) error {
147147- _, err := c.db.Exec(`
148148- INSERT INTO readme_cache (url, html, fetched_at)
149149- VALUES (?, ?, ?)
150150- ON CONFLICT(url) DO UPDATE SET
151151- html = excluded.html,
152152- fetched_at = excluded.fetched_at
153153- `, readmeURL, html, time.Now())
154154-155155- return err
156156-}
157157-158158-// Invalidate removes a README from the cache
159159-func (c *Cache) Invalidate(readmeURL string) error {
160160- _, err := c.db.Exec(`
161161- DELETE FROM readme_cache
162162- WHERE url = ?
163163- `, readmeURL)
164164- return err
165165-}
166166-167167-// Cleanup removes expired entries from the cache
168168-func (c *Cache) Cleanup() error {
169169- cutoff := time.Now().Add(-c.ttl * 2) // Keep for 2x TTL
170170- _, err := c.db.Exec(`
171171- DELETE FROM readme_cache
172172- WHERE fetched_at < ?
173173- `, cutoff)
174174- return err
175175-}
-256
pkg/appview/readme/cache_test.go
···11-package readme
22-33-import (
44- "context"
55- "database/sql"
66- "fmt"
77- "testing"
88- "time"
99-1010- _ "github.com/mattn/go-sqlite3"
1111-)
1212-1313-func TestCache_Struct(t *testing.T) {
1414- // Simple struct test
1515- cache := &Cache{}
1616- if cache == nil {
1717- t.Error("Expected non-nil cache")
1818- }
1919-}
2020-2121-func setupTestDB(t *testing.T) *sql.DB {
2222- t.Helper()
2323- db, err := sql.Open("sqlite3", ":memory:")
2424- if err != nil {
2525- t.Fatalf("Failed to open database: %v", err)
2626- }
2727-2828- // Create the readme_cache table
2929- _, err = db.Exec(`
3030- CREATE TABLE readme_cache (
3131- url TEXT PRIMARY KEY,
3232- html TEXT NOT NULL,
3333- fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
3434- )
3535- `)
3636- if err != nil {
3737- t.Fatalf("Failed to create table: %v", err)
3838- }
3939-4040- return db
4141-}
4242-4343-func TestGetFromSource_UnsupportedPlatform(t *testing.T) {
4444- db := setupTestDB(t)
4545- defer db.Close()
4646-4747- cache := NewCache(db, time.Hour)
4848- ctx := context.Background()
4949-5050- // Unsupported platform should return empty, no error
5151- html, err := cache.GetFromSource(ctx, "https://bitbucket.org/user/repo")
5252- if err != nil {
5353- t.Errorf("Expected no error for unsupported platform, got: %v", err)
5454- }
5555- if html != "" {
5656- t.Errorf("Expected empty string for unsupported platform, got: %q", html)
5757- }
5858-}
5959-6060-func TestGetFromSource_CacheHit(t *testing.T) {
6161- db := setupTestDB(t)
6262- defer db.Close()
6363-6464- cache := NewCache(db, time.Hour)
6565- sourceURL := "https://github.com/test/repo"
6666- cacheKey := sourceCachePrefix + sourceURL
6767- expectedHTML := "<h1>Cached Content</h1>"
6868-6969- // Pre-populate cache
7070- _, err := db.Exec(`
7171- INSERT INTO readme_cache (url, html, fetched_at)
7272- VALUES (?, ?, ?)
7373- `, cacheKey, expectedHTML, time.Now())
7474- if err != nil {
7575- t.Fatalf("Failed to insert cache: %v", err)
7676- }
7777-7878- ctx := context.Background()
7979- html, err := cache.GetFromSource(ctx, sourceURL)
8080- if err != nil {
8181- t.Errorf("Expected no error, got: %v", err)
8282- }
8383- if html != expectedHTML {
8484- t.Errorf("Expected %q, got %q", expectedHTML, html)
8585- }
8686-}
8787-8888-func TestGetFromSource_CacheExpired(t *testing.T) {
8989- db := setupTestDB(t)
9090- defer db.Close()
9191-9292- cache := NewCache(db, time.Millisecond) // Very short TTL
9393- sourceURL := "https://github.com/test/repo"
9494- cacheKey := sourceCachePrefix + sourceURL
9595- oldHTML := "<h1>Old Content</h1>"
9696-9797- // Pre-populate cache with old timestamp
9898- _, err := db.Exec(`
9999- INSERT INTO readme_cache (url, html, fetched_at)
100100- VALUES (?, ?, ?)
101101- `, cacheKey, oldHTML, time.Now().Add(-time.Hour))
102102- if err != nil {
103103- t.Fatalf("Failed to insert cache: %v", err)
104104- }
105105-106106- ctx := context.Background()
107107-108108- // With expired cache and no network (GitHub won't respond), we expect an error
109109- // but the function should try to fetch
110110- _, err = cache.GetFromSource(ctx, sourceURL)
111111- // We expect an error because we can't actually fetch from GitHub in tests
112112- // The important thing is that it tried to fetch (didn't return cached content)
113113- if err == nil {
114114- t.Log("Note: GetFromSource returned no error - cache was expired and fetch was attempted")
115115- }
116116-}
117117-118118-func TestGetFromSource_NegativeCache(t *testing.T) {
119119- db := setupTestDB(t)
120120- defer db.Close()
121121-122122- cache := NewCache(db, time.Hour)
123123- sourceURL := "https://github.com/test/repo"
124124- cacheKey := sourceCachePrefix + sourceURL
125125-126126- // Pre-populate cache with empty string (negative cache)
127127- _, err := db.Exec(`
128128- INSERT INTO readme_cache (url, html, fetched_at)
129129- VALUES (?, ?, ?)
130130- `, cacheKey, "", time.Now())
131131- if err != nil {
132132- t.Fatalf("Failed to insert cache: %v", err)
133133- }
134134-135135- ctx := context.Background()
136136- html, err := cache.GetFromSource(ctx, sourceURL)
137137- if err != nil {
138138- t.Errorf("Expected no error for negative cache hit, got: %v", err)
139139- }
140140- if html != "" {
141141- t.Errorf("Expected empty string for negative cache hit, got: %q", html)
142142- }
143143-}
144144-145145-func TestGetFromSource_NegativeCacheExpired(t *testing.T) {
146146- db := setupTestDB(t)
147147- defer db.Close()
148148-149149- cache := NewCache(db, time.Hour)
150150- sourceURL := "https://github.com/test/repo"
151151- cacheKey := sourceCachePrefix + sourceURL
152152-153153- // Pre-populate cache with expired negative cache (older than negativeCacheTTL)
154154- _, err := db.Exec(`
155155- INSERT INTO readme_cache (url, html, fetched_at)
156156- VALUES (?, ?, ?)
157157- `, cacheKey, "", time.Now().Add(-30*time.Minute)) // 30 min ago, negative TTL is 15 min
158158- if err != nil {
159159- t.Fatalf("Failed to insert cache: %v", err)
160160- }
161161-162162- ctx := context.Background()
163163-164164- // With expired negative cache, it should try to fetch again
165165- _, err = cache.GetFromSource(ctx, sourceURL)
166166- // We expect an error because we can't actually fetch from GitHub
167167- // The important thing is that it tried (didn't return empty from expired negative cache)
168168- if err == nil {
169169- t.Log("Note: GetFromSource attempted refetch after negative cache expired")
170170- }
171171-}
172172-173173-func TestGetFromSource_EmptyURL(t *testing.T) {
174174- db := setupTestDB(t)
175175- defer db.Close()
176176-177177- cache := NewCache(db, time.Hour)
178178- ctx := context.Background()
179179-180180- html, err := cache.GetFromSource(ctx, "")
181181- if err != nil {
182182- t.Errorf("Expected no error for empty URL, got: %v", err)
183183- }
184184- if html != "" {
185185- t.Errorf("Expected empty string for empty URL, got: %q", html)
186186- }
187187-}
188188-189189-func TestGetFromSource_UnsupportedPlatforms(t *testing.T) {
190190- db := setupTestDB(t)
191191- defer db.Close()
192192-193193- cache := NewCache(db, time.Hour)
194194- ctx := context.Background()
195195-196196- unsupportedURLs := []string{
197197- "https://bitbucket.org/user/repo",
198198- "https://sourcehut.org/user/repo",
199199- "https://codeberg.org/user/repo",
200200- "ftp://github.com/user/repo",
201201- "not-a-url",
202202- }
203203-204204- for _, url := range unsupportedURLs {
205205- html, err := cache.GetFromSource(ctx, url)
206206- if err != nil {
207207- t.Errorf("Expected no error for unsupported URL %q, got: %v", url, err)
208208- }
209209- if html != "" {
210210- t.Errorf("Expected empty string for unsupported URL %q, got: %q", url, html)
211211- }
212212- }
213213-}
214214-215215-func TestIs404(t *testing.T) {
216216- tests := []struct {
217217- name string
218218- err error
219219- want bool
220220- }{
221221- {
222222- name: "nil error",
223223- err: nil,
224224- want: false,
225225- },
226226- {
227227- name: "404 error",
228228- err: fmt.Errorf("unexpected status code: 404"),
229229- want: true,
230230- },
231231- {
232232- name: "404 error with context",
233233- err: fmt.Errorf("failed to fetch: unexpected status code: 404"),
234234- want: true,
235235- },
236236- {
237237- name: "500 error",
238238- err: fmt.Errorf("unexpected status code: 500"),
239239- want: false,
240240- },
241241- {
242242- name: "network error",
243243- err: fmt.Errorf("connection refused"),
244244- want: false,
245245- },
246246- }
247247-248248- for _, tt := range tests {
249249- t.Run(tt.name, func(t *testing.T) {
250250- got := Is404(tt.err)
251251- if got != tt.want {
252252- t.Errorf("Is404(%v) = %v, want %v", tt.err, got, tt.want)
253253- }
254254- })
255255- }
256256-}
+7
pkg/appview/readme/fetcher.go
···185185 return err != nil && strings.Contains(err.Error(), "unexpected status code: 404")
186186}
187187188188+// RenderMarkdown renders a markdown string to sanitized HTML
189189+// This is used for rendering repo page descriptions stored in the database
190190+func (f *Fetcher) RenderMarkdown(content []byte) (string, error) {
191191+ // Render markdown to HTML (no base URL for repo page descriptions)
192192+ return f.renderMarkdown(content, "")
193193+}
194194+188195// rewriteRelativeURLs converts relative URLs to absolute URLs
189196func rewriteRelativeURLs(html, baseURL string) string {
190197 if baseURL == "" {