···55 "encoding/json"
66 "fmt"
77 "net/url"
88+ "regexp"
89 "strings"
910 "time"
1011)
···192193193194 var total int
194195 if err := db.QueryRow(countQuery, currentUserDID, currentUserDID, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil {
196196+ return nil, 0, err
197197+ }
198198+199199+ if err := PopulateRepoCardTags(db, cards); err != nil {
195200 return nil, 0, err
196201 }
197202···21432148 return nil, err
21442149 }
2145215021512151+ if err := PopulateRepoCardTags(db, cards); err != nil {
21522152+ return nil, err
21532153+ }
21542154+21462155 return cards, nil
21472156}
21482157···22132222 }
2214222322152224 if err := rows.Err(); err != nil {
22252225+ return nil, err
22262226+ }
22272227+22282228+ if err := PopulateRepoCardTags(db, cards); err != nil {
22162229 return nil, err
22172230 }
22182231···25612574 return holds, rows.Err()
25622575}
2563257625772577+// TagNameDigest is a lightweight (tag, digest) pair used for dropdown
25782578+// population and default-selection heuristics.
25792579+type TagNameDigest struct {
25802580+ Name string
25812581+ Digest string
25822582+}
25832583+25842584+// shaTagPattern matches CI-style git-sha tags like "sha-937fa4c".
25852585+var shaTagPattern = regexp.MustCompile(`^sha-[0-9a-f]{6,40}$`)
25862586+25872587+// PickDefaultTag chooses the best display tag from a list of (name, digest)
25882588+// pairs ordered most-recent first.
25892589+//
25902590+// 1. Start with the newest tag.
25912591+// 2. If that newest tag looks like a git-sha tag, look for a sibling with
25922592+// the same digest that doesn't — happyview-style repos push both
25932593+// "sha-937fa4c" and "2.0.0-dev.45" pointing at the same image; we'd
25942594+// rather show the semver name.
25952595+// 3. If "latest" exists AND points to the same digest as the chosen tag,
25962596+// prefer "latest" as the friendliest label. A stale "latest" pointing
25972597+// at an old digest is bypassed.
25982598+func PickDefaultTag(tags []TagNameDigest) string {
25992599+ if len(tags) == 0 {
26002600+ return ""
26012601+ }
26022602+ chosen := tags[0]
26032603+ if shaTagPattern.MatchString(chosen.Name) {
26042604+ for _, t := range tags[1:] {
26052605+ if t.Digest == chosen.Digest && !shaTagPattern.MatchString(t.Name) {
26062606+ chosen = t
26072607+ break
26082608+ }
26092609+ }
26102610+ }
26112611+ if chosen.Name != "latest" {
26122612+ for _, t := range tags {
26132613+ if t.Name == "latest" && t.Digest == chosen.Digest {
26142614+ return "latest"
26152615+ }
26162616+ }
26172617+ }
26182618+ return chosen.Name
26192619+}
26202620+26212621+// PopulateRepoCardTags overrides each card's Tag field with the best display
26222622+// tag chosen by PickDefaultTag. Issues one batch query for all (handle, repository)
26232623+// pairs in the slice. No-op for an empty slice.
26242624+//
26252625+// RepoCardData doesn't carry the owner DID, so we join through users.handle.
26262626+// This is fine because (handle, repository) is unique within the appview.
26272627+func PopulateRepoCardTags(db DBTX, cards []RepoCardData) error {
26282628+ if len(cards) == 0 {
26292629+ return nil
26302630+ }
26312631+ type key struct{ handle, repo string }
26322632+ placeholders := make([]string, 0, len(cards))
26332633+ args := make([]any, 0, len(cards)*2)
26342634+ for _, c := range cards {
26352635+ placeholders = append(placeholders, "(?, ?)")
26362636+ args = append(args, c.OwnerHandle, c.Repository)
26372637+ }
26382638+ q := `
26392639+ SELECT u.handle, t.repository, t.tag, t.digest
26402640+ FROM tags t
26412641+ JOIN users u ON t.did = u.did
26422642+ WHERE (u.handle, t.repository) IN (VALUES ` + strings.Join(placeholders, ",") + `)
26432643+ ORDER BY t.repository, t.created_at DESC
26442644+ `
26452645+ rows, err := db.Query(q, args...)
26462646+ if err != nil {
26472647+ return err
26482648+ }
26492649+ defer rows.Close()
26502650+ groups := make(map[key][]TagNameDigest)
26512651+ for rows.Next() {
26522652+ var handle, repo, tag, digest string
26532653+ if err := rows.Scan(&handle, &repo, &tag, &digest); err != nil {
26542654+ return err
26552655+ }
26562656+ k := key{handle, repo}
26572657+ groups[k] = append(groups[k], TagNameDigest{Name: tag, Digest: digest})
26582658+ }
26592659+ if err := rows.Err(); err != nil {
26602660+ return err
26612661+ }
26622662+ for i := range cards {
26632663+ k := key{cards[i].OwnerHandle, cards[i].Repository}
26642664+ if g, ok := groups[k]; ok && len(g) > 0 {
26652665+ cards[i].Tag = PickDefaultTag(g)
26662666+ }
26672667+ }
26682668+ return nil
26692669+}
26702670+25642671// GetAllTagNames returns all tag names for a repository, ordered by most recent first.
25652672// Filters out tags whose manifests live on holds the viewer can't access.
25662673func GetAllTagNames(db DBTX, did, repository string, viewerDID string) ([]string, error) {
26742674+ pairs, err := GetAllTagsWithDigests(db, did, repository, viewerDID)
26752675+ if err != nil {
26762676+ return nil, err
26772677+ }
26782678+ names := make([]string, len(pairs))
26792679+ for i, p := range pairs {
26802680+ names[i] = p.Name
26812681+ }
26822682+ return names, nil
26832683+}
26842684+26852685+// GetAllTagsWithDigests returns all tags for a repository with their manifest
26862686+// digests, ordered by most recent first. Filters out tags whose manifests live
26872687+// on holds the viewer can't access.
26882688+func GetAllTagsWithDigests(db DBTX, did, repository string, viewerDID string) ([]TagNameDigest, error) {
25672689 rows, err := db.Query(`
25682568- SELECT t.tag FROM tags t
26902690+ SELECT t.tag, t.digest FROM tags t
25692691 JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
25702692 WHERE t.did = ? AND t.repository = ?
25712693 AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
···25762698 }
25772699 defer rows.Close()
2578270025792579- var names []string
27012701+ var out []TagNameDigest
25802702 for rows.Next() {
25812581- var name string
25822582- if err := rows.Scan(&name); err != nil {
27032703+ var p TagNameDigest
27042704+ if err := rows.Scan(&p.Name, &p.Digest); err != nil {
25832705 return nil, err
25842706 }
25852585- names = append(names, name)
27072707+ out = append(out, p)
25862708 }
25872587- return names, rows.Err()
27092709+ return out, rows.Err()
25882710}
2589271125902712// GetLayerCountForManifest returns the number of layers for a manifest identified by digest.
+8-13
pkg/appview/handlers/repository.go
···7878 viewerDID = vu.DID
7979 }
80808181- // Fetch all tag names for the selector dropdown
8282- allTags, err := db.GetAllTagNames(h.ReadOnlyDB, owner.DID, repository, viewerDID)
8181+ // Fetch all tags (with digests) for the dropdown and default-selection heuristics.
8282+ tagPairs, err := db.GetAllTagsWithDigests(h.ReadOnlyDB, owner.DID, repository, viewerDID)
8383 if err != nil {
8484 slog.Warn("Failed to fetch tag names", "error", err)
8585 }
8686+ allTags := make([]string, len(tagPairs))
8787+ for i, p := range tagPairs {
8888+ allTags[i] = p.Name
8989+ }
86908791 // Determine which tag to show
8892 selectedTagName := r.URL.Query().Get("tag")
8989- if selectedTagName == "" {
9090- // Default: "latest" if it exists, otherwise most recent
9191- for _, t := range allTags {
9292- if t == "latest" {
9393- selectedTagName = "latest"
9494- break
9595- }
9696- }
9797- if selectedTagName == "" && len(allTags) > 0 {
9898- selectedTagName = allTags[0] // most recent (already sorted DESC)
9999- }
9393+ if selectedTagName == "" && len(tagPairs) > 0 {
9494+ selectedTagName = db.PickDefaultTag(tagPairs)
10095 }
1019610297 // Fetch the selected tag's full data
+11-7
pkg/appview/handlers/sbom_details.go
···36363737// sbomDetailsData is the template data for the sbom-details partial.
3838type sbomDetailsData struct {
3939- Packages []sbomPackage
4040- Total int
4141- Error string
4242- ScannedAt string
3939+ Packages []sbomPackage
4040+ Total int
4141+ Error string
4242+ ScannedAt string
4343+ Digest string // image digest (for download URLs)
4444+ HoldEndpoint string // hold DID (for download URLs)
4345}
44464547type sbomPackage struct {
···199201 })
200202201203 return sbomDetailsData{
202202- Packages: packages,
203203- Total: len(packages),
204204- ScannedAt: scanRecord.ScannedAt,
204204+ Packages: packages,
205205+ Total: len(packages),
206206+ ScannedAt: scanRecord.ScannedAt,
207207+ Digest: digest,
208208+ HoldEndpoint: holdEndpoint,
205209 }
206210}
207211