minor bug fixes, add ability see starred repos

This commit is contained in:
Evan Jarrett
2026-05-06 21:55:47 -05:00
parent 56f3b2fc2f
commit 3533f07ecb
11 changed files with 411 additions and 11 deletions
+88
View File
@@ -2360,6 +2360,94 @@ func GetUserRepoCards(db DBTX, userDID string, currentUserDID string) ([]RepoCar
return cards, nil
}
// GetStarredRepoCards fetches repository cards for repositories starred by
// starrerDID. Stars whose target repo no longer has a manifest, lives on a
// hold the viewer can't access, or is currently taken down are silently
// dropped via the joins and filters. Ordered by star creation time DESC.
func GetStarredRepoCards(db DBTX, starrerDID string, currentUserDID string) ([]RepoCardData, error) {
query := `
WITH starred AS (
SELECT owner_did AS did, repository, created_at AS starred_at
FROM stars
WHERE starrer_did = ?
),
latest_manifests AS (
SELECT m.did, m.repository, MAX(m.id) as latest_id
FROM manifests m
JOIN starred st ON m.did = st.did AND m.repository = st.repository
WHERE m.hold_endpoint IN ` + accessibleHoldsSubquery + `
GROUP BY m.did, m.repository
)
SELECT
m.did,
u.handle,
COALESCE(u.avatar, ''),
m.repository,
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = m.did AND repository = m.repository), 0),
COALESCE(rs.pull_count, 0),
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
COALESCE(m.artifact_type, 'container-image'),
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
COALESCE(m.digest, ''),
MAX(rs.last_push, m.created_at),
COALESCE(rp.avatar_cid, ''),
st.starred_at
FROM latest_manifests lm
JOIN manifests m ON lm.latest_id = m.id
JOIN users u ON m.did = u.did
JOIN starred st ON st.did = m.did AND st.repository = m.repository
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
WHERE ` + activeTakedownClause("m") + `
ORDER BY st.starred_at DESC
`
rows, err := db.Query(query, starrerDID, currentUserDID, currentUserDID, currentUserDID)
if err != nil {
return nil, err
}
defer rows.Close()
var cards []RepoCardData
for rows.Next() {
var c RepoCardData
var ownerDID string
var isStarredInt int
var avatarCID string
var lastUpdatedStr sql.NullString
var starredAtStr sql.NullString
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.OwnerAvatarURL, &c.Repository, &c.Title, &c.Description, &c.IconURL,
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID, &starredAtStr); err != nil {
return nil, err
}
c.IsStarred = isStarredInt > 0
if lastUpdatedStr.Valid {
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
c.LastUpdated = t
}
}
if avatarCID != "" {
c.IconURL = BlobCDNURL(ownerDID, avatarCID)
}
cards = append(cards, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := PopulateRepoCardTags(db, cards); err != nil {
return nil, err
}
return cards, nil
}
// RepoPage represents a repository page record cached from PDS
type RepoPage struct {
DID string
+84
View File
@@ -1744,6 +1744,90 @@ func TestGetUserRepositories_BulkGrouping(t *testing.T) {
}
}
// TestGetStarredRepoCards verifies the listing of repos starred by a user:
// stars whose target repo no longer has a manifest are silently dropped (the
// "still exists" filter the feature relies on), and results are ordered by
// star creation time DESC.
func TestGetStarredRepoCards(t *testing.T) {
db, err := InitDB("file:TestGetStarredRepoCards?mode=memory&cache=shared", LibsqlConfig{})
if err != nil {
t.Fatalf("init db: %v", err)
}
defer db.Close()
now := time.Now().UTC().Truncate(time.Second)
mediaType := "application/vnd.oci.image.manifest.v1+json"
starrer := &User{DID: "did:plc:starrer", Handle: "starrer.test", PDSEndpoint: "https://pds.example", LastSeen: now}
if err := UpsertUser(db, starrer); err != nil {
t.Fatalf("upsert starrer: %v", err)
}
owner := &User{DID: "did:plc:owner", Handle: "owner.test", PDSEndpoint: "https://pds.example", LastSeen: now}
if err := UpsertUser(db, owner); err != nil {
t.Fatalf("upsert owner: %v", err)
}
// Owner of a deleted repo (still has a users row, just no manifests).
ghost := &User{DID: "did:plc:ghost", Handle: "ghost.test", PDSEndpoint: "https://pds.example", LastSeen: now}
if err := UpsertUser(db, ghost); err != nil {
t.Fatalf("upsert ghost: %v", err)
}
if err := UpsertCaptainRecord(db, &HoldCaptainRecord{
HoldDID: "did:web:hold.example", OwnerDID: "did:plc:holdowner", Public: true,
}); err != nil {
t.Fatalf("seed captain: %v", err)
}
// Two existing repos owned by `owner`.
if _, err := InsertManifest(db, &Manifest{
DID: owner.DID, Repository: "repo-old", Digest: "sha256:old",
HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
CreatedAt: now,
}); err != nil {
t.Fatalf("insert old manifest: %v", err)
}
if _, err := InsertManifest(db, &Manifest{
DID: owner.DID, Repository: "repo-new", Digest: "sha256:new",
HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
CreatedAt: now.Add(5 * time.Second),
}); err != nil {
t.Fatalf("insert new manifest: %v", err)
}
// Three stars: two for existing repos, one for a deleted repo.
if err := UpsertStar(db, starrer.DID, owner.DID, "repo-old", now); err != nil {
t.Fatalf("upsert star repo-old: %v", err)
}
if err := UpsertStar(db, starrer.DID, owner.DID, "repo-new", now.Add(10*time.Second)); err != nil {
t.Fatalf("upsert star repo-new: %v", err)
}
if err := UpsertStar(db, starrer.DID, ghost.DID, "deleted-repo", now.Add(20*time.Second)); err != nil {
t.Fatalf("upsert star deleted-repo: %v", err)
}
cards, err := GetStarredRepoCards(db, starrer.DID, starrer.DID)
if err != nil {
t.Fatalf("GetStarredRepoCards: %v", err)
}
if len(cards) != 2 {
t.Fatalf("expected 2 cards (deleted repo dropped), got %d: %+v", len(cards), cards)
}
// Newest star first.
if cards[0].Repository != "repo-new" || cards[1].Repository != "repo-old" {
t.Errorf("expected order [repo-new, repo-old] (newest star first), got [%s, %s]", cards[0].Repository, cards[1].Repository)
}
// IsStarred should reflect the viewer's perspective. Viewer == starrer here
// so every returned row is starred-by-viewer.
for _, c := range cards {
if !c.IsStarred {
t.Errorf("card %s/%s expected IsStarred=true for self-viewer", c.OwnerHandle, c.Repository)
}
}
}
// TestGetUserRepositories_Empty verifies the bulk-fetch path short-circuits
// cleanly when the summary query returns no rows (no extra queries issued,
// nil slice returned).
+81
View File
@@ -107,3 +107,84 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
}
// StarredRepositoriesHandler renders /u/{handle}/starred — the list of repos
// the viewed user has starred. Stars whose target repo no longer exists or is
// inaccessible are silently dropped by the underlying query.
type StarredRepositoriesHandler struct {
BaseUIHandler
}
func (h *StarredRepositoriesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "handle")
did, resolvedHandle, pdsEndpoint, err := atproto.ResolveIdentity(r.Context(), identifier)
if err != nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
viewedUser, err := db.GetUserByDID(h.ReadOnlyDB, did)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hasProfile := true
if viewedUser == nil {
hasProfile = false
viewedUser = &db.User{
DID: did,
Handle: resolvedHandle,
PDSEndpoint: pdsEndpoint,
}
} else if viewedUser.Handle != resolvedHandle {
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
viewedUser.Handle = resolvedHandle
}
var currentUserDID string
if user := middleware.GetUser(r); user != nil {
currentUserDID = user.DID
}
var cardsErr bool
cards, err := db.GetStarredRepoCards(h.ReadOnlyDB, viewedUser.DID, currentUserDID)
if err != nil {
slog.Error("starred: fetch repo cards", "did", viewedUser.DID, "err", err)
cards = []db.RepoCardData{}
cardsErr = true
}
db.SetRegistryURL(cards, h.RegistryURL)
pageData := NewPageData(r, &h.BaseUIHandler)
db.SetOciClient(cards, pageData.OciClient)
meta := NewPageMeta(
viewedUser.Handle+"'s stars - "+h.ClientShortName,
"Repositories starred by "+viewedUser.Handle+" on "+h.ClientShortName+", the decentralized container registry",
).
WithCanonical("https://" + h.SiteURL + "/u/" + viewedUser.Handle + "/starred").
WithSiteName(h.ClientShortName)
data := struct {
PageData
Meta *PageMeta
ViewedUser *db.User
Repositories []db.RepoCardData
HasProfile bool
HasError bool
}{
PageData: pageData,
Meta: meta,
ViewedUser: viewedUser,
Repositories: cards,
HasProfile: hasProfile,
HasError: cardsErr,
}
if err := h.Templates.ExecuteTemplate(w, "user-starred", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
+8 -5
View File
@@ -496,18 +496,21 @@ func (p *Processor) ProcessTag(ctx context.Context, did string, recordData []byt
})
}
// ProcessStar processes a star record and stores it in the database
// ProcessStar processes a star record and stores it in the database.
// Records that don't match the current schema (bad JSON or unparseable
// subject AT URI) are skipped with a warning rather than failing the event,
// matching the backfill behavior.
func (p *Processor) ProcessStar(ctx context.Context, did string, recordData []byte) error {
// Unmarshal star record (handles both old object and new AT URI subject formats)
var starRecord atproto.StarRecord
if err := json.Unmarshal(recordData, &starRecord); err != nil {
return fmt.Errorf("failed to unmarshal star: %w", err)
slog.Warn("Skipping invalid star record", "component", "processor", "did", did, "error", err)
return nil
}
// Extract owner DID and repository from subject AT URI
ownerDID, repository, err := starRecord.GetSubjectDIDAndRepository()
if err != nil {
return fmt.Errorf("failed to parse star subject: %w", err)
slog.Warn("Skipping star with bad subject", "component", "processor", "did", did, "error", err)
return nil
}
// Ensure the starred repository's owner exists in the users table
+44
View File
@@ -489,6 +489,50 @@ func TestProcessStar(t *testing.T) {
}
}
// TestProcessStar_InvalidRecord verifies that star records that don't match
// the current schema (bad JSON or unparseable subject AT URI) are skipped
// with a warning rather than failing the firehose event. Regression guard
// for the behavior change from "return error" to "log warn + return nil".
func TestProcessStar_InvalidRecord(t *testing.T) {
database := setupTestDB(t)
defer database.Close()
if _, err := database.Exec(
`INSERT INTO users (did, handle, pds_endpoint, last_seen) VALUES (?, ?, ?, ?)`,
"did:plc:starrer123", "starrer.test", "https://pds.example.com", time.Now()); err != nil {
t.Fatalf("Failed to insert starrer: %v", err)
}
p := NewProcessor(database, false, nil)
ctx := context.Background()
cases := []struct {
name string
body []byte
}{
{name: "garbage JSON", body: []byte("not json at all")},
{name: "missing subject", body: []byte(`{"$type":"io.atcr.sailor.star","createdAt":"2025-01-01T00:00:00Z"}`)},
{name: "non-AT-URI subject", body: []byte(`{"$type":"io.atcr.sailor.star","subject":"https://example.com/notaturi","createdAt":"2025-01-01T00:00:00Z"}`)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := p.ProcessStar(ctx, "did:plc:starrer123", tc.body); err != nil {
t.Errorf("ProcessStar with %s: expected nil error (skip-with-warning), got %v", tc.name, err)
}
})
}
// No rows should have been inserted.
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM stars WHERE starrer_did = ?", "did:plc:starrer123").Scan(&count); err != nil {
t.Fatalf("query stars: %v", err)
}
if count != 0 {
t.Errorf("expected 0 stars after invalid records, got %d", count)
}
}
func TestProcessManifest_Duplicate(t *testing.T) {
database := setupTestDB(t)
defer database.Close()
+4
View File
@@ -155,6 +155,10 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
&uihandlers.UserPageHandler{BaseUIHandler: base},
).ServeHTTP)
router.Get("/u/{handle}/starred", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.StarredRepositoriesHandler{BaseUIHandler: base},
).ServeHTTP)
// OpenGraph image generation (public, cacheable)
router.Get("/og/home", (&uihandlers.DefaultOGHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/og/u/{handle}", (&uihandlers.UserOGHandler{BaseUIHandler: base}).ServeHTTP)
+9
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"atcr.io/pkg/atproto"
"github.com/distribution/distribution/v3"
@@ -121,6 +122,7 @@ func (s *TagStore) List(ctx context.Context, limit int, last string) ([]string,
var result []string
past := last == ""
reachedLimit := false
for _, tag := range all {
if !past {
if tag == last {
@@ -130,9 +132,16 @@ func (s *TagStore) List(ctx context.Context, limit int, last string) ([]string,
}
result = append(result, tag)
if limit > 0 && len(result) >= limit {
reachedLimit = true
break
}
}
// Signal end-of-list to the upstream handler so it knows there are no
// more entries to paginate over. Without io.EOF the handler indexes
// filled[len(filled)-1] and panics on an empty slice.
if !reachedLimit {
return result, io.EOF
}
return result, nil
}
@@ -21,6 +21,7 @@
</summary>
<ul class="dropdown-content menu bg-base-200 text-base-content rounded-box z-50 w-52 p-2 shadow-lg">
<li><a href="/u/{{ .User.Handle }}">Your Repositories</a></li>
<li><a href="/u/{{ .User.Handle }}/starred">Starred Repositories</a></li>
<li><a href="/settings">Settings</a></li>
<li class="border-t border-base-300 mt-2 pt-2">
<button type="submit" form="logout-form" class="text-error">Logout</button>
@@ -0,0 +1,58 @@
{{ define "user-starred" }}
<!DOCTYPE html>
<html lang="en">
<head>
{{ template "head" . }}
{{ template "meta" .Meta }}
</head>
<body>
{{ template "nav" . }}
<main id="main-content" class="container mx-auto px-4 py-8">
<div class="flex flex-col items-center gap-8">
<div class="flex flex-col items-center gap-4">
{{ if .ViewedUser.Avatar }}
<div class="avatar">
<div class="w-20 rounded-full shadow">
<img src="{{ resizeImage .ViewedUser.Avatar 160 }}" alt="" aria-hidden="true" width="80" height="80" fetchpriority="high" />
</div>
</div>
{{ else if .HasProfile }}
<div class="avatar avatar-placeholder" role="img" aria-label="Avatar for {{ .ViewedUser.Handle }}">
<div class="bg-neutral text-neutral-content w-20 rounded-full shadow">
<span aria-hidden="true" class="text-3xl">{{ firstChar .ViewedUser.Handle }}</span>
</div>
</div>
{{ else }}
<div class="avatar avatar-placeholder" role="img" aria-label="Unknown user avatar">
<div class="bg-neutral text-neutral-content/60 w-20 rounded-full shadow">
<span aria-hidden="true" class="text-3xl">?</span>
</div>
</div>
{{ end }}
<div class="flex flex-col items-center gap-1">
<h1 class="text-2xl md:text-3xl font-display font-bold tracking-tight break-all min-w-0">
<a href="/u/{{ .ViewedUser.Handle }}" class="link link-hover">{{ .ViewedUser.Handle }}</a>
<span class="text-base-content/60 font-normal"> / starred</span>
</h1>
</div>
</div>
{{ if .HasError }}
{{ template "state-error" (dict
"Title" "We couldn't load their stars"
"Subtext" "The database had trouble fetching this list. Try refreshing in a moment."
"RetryURL" (printf "/u/%s/starred" .ViewedUser.Handle)
) }}
{{ else }}
<div class="w-full">
{{ template "card-grid" (dict "Repositories" .Repositories "Columns" 4 "EmptyMessage" "No starred repositories yet.") }}
</div>
{{ end }}
</div>
</main>
{{ template "footer" . }}
</body>
</html>
{{ end }}
+3 -6
View File
@@ -121,15 +121,12 @@ func humanizeCount(v any) string {
case n < 1000:
s = fmt.Sprintf("%d", n)
case n < 1_000_000:
s = fmt.Sprintf("%.1fK", float64(n)/1000)
s = strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1000), ".0") + "K"
case n < 1_000_000_000:
s = fmt.Sprintf("%.1fM", float64(n)/1_000_000)
s = strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0") + "M"
default:
s = fmt.Sprintf("%.1fB", float64(n)/1_000_000_000)
s = strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000_000), ".0") + "B"
}
s = strings.TrimSuffix(s, ".0K")
s = strings.TrimSuffix(s, ".0M")
s = strings.TrimSuffix(s, ".0B")
if neg {
return "-" + s
}
+31
View File
@@ -187,6 +187,37 @@ func TestHumanizeBytes(t *testing.T) {
}
}
func TestHumanizeCount(t *testing.T) {
tests := []struct {
name string
count any
expected string
}{
{"zero", 0, "0"},
{"under thousand", 999, "999"},
{"exactly 1K", 1000, "1K"},
{"1.5K", 1500, "1.5K"},
{"2049 (bug repro)", 2049, "2K"},
{"5000 (whole-thousand)", 5000, "5K"},
{"exactly 1M", 1_000_000, "1M"},
{"2.5M", 2_500_000, "2.5M"},
{"exactly 1B", 1_000_000_000, "1B"},
{"1.5B", 1_500_000_000, "1.5B"},
{"negative", -2049, "-2K"},
{"int64 input", int64(2049), "2K"},
{"non-integer", "oops", "0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := humanizeCount(tt.count)
if got != tt.expected {
t.Errorf("humanizeCount(%v) = %q, want %q", tt.count, got, tt.expected)
}
})
}
}
func TestTruncateDigest(t *testing.T) {
tests := []struct {
name string