feat: deduplicate ranking table entries sharing the same image digest - #36
Merged
Merged
Conversation
Images with different tags but identical digests (e.g., nodejs:24 and nodejs:24.14) now appear as a single row. The most specific tag is shown as primary, with alternates in an "Also Tagged As" column. Deduplication is applied at report generation time for both markdown and JSON reports. No database schema changes. Closes #35 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mani Bindra (maniSbindra)
requested a review
from Dariusz Porowski (DariuszPorowski)
as a code owner
May 25, 2026 12:31
There was a problem hiding this comment.
Pull request overview
This PR improves the readability of the generated recommendation reports by deduplicating ranking rows that refer to the same underlying image digest, surfacing the most specific tag as the primary entry and listing other tags as alternates.
Changes:
- Added
AlternateTagstodomain.RecommendedImageand populated it via newDeduplicateByDigestlogic. - Updated markdown and JSON report generation to query all rows, deduplicate by digest, then apply
topNpost-dedup; markdown now includes an “Also Tagged As” column. - Added unit tests for deduplication behavior and updated existing report tests for the new column.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/domain/models.go | Extends RecommendedImage with AlternateTags. |
| pkg/infrastructure/report/dedup.go | New digest-based deduplication + tag specificity scoring. |
| pkg/infrastructure/report/dedup_test.go | Unit tests covering dedup and tag parsing/scoring. |
| pkg/infrastructure/report/markdown.go | Applies dedup + post-dedup topN; adds “Also Tagged As” column. |
| pkg/infrastructure/report/markdown_test.go | Updates expectations for the new markdown table column. |
| pkg/infrastructure/report/json.go | Applies same dedup flow; adds alternateTags to JSON output. |
| pkg/infrastructure/report/json_test.go | Updates markdown-table expectations in an integration-style test. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+80
to
+89
| images, err := repo.QueryTopImagesByOS(lang, osName, 0) | ||
| if err != nil { | ||
| log.Warnf("Failed to query images for %s: %v", lang, err) | ||
| continue | ||
| } | ||
|
|
||
| images = DeduplicateByDigest(images) | ||
| if topN > 0 && len(images) > topN { | ||
| images = images[:topN] | ||
| } |
Comment on lines
+96
to
+105
| osImages, err := repo.QueryTopImagesByOS(lang, osName, 0) | ||
| if err != nil { | ||
| log.Warnf("Failed to query images for %s/%s: %v", lang, osName, err) | ||
| continue | ||
| } | ||
|
|
||
| osImages = DeduplicateByDigest(osImages) | ||
| if topN > 0 && len(osImages) > topN { | ||
| osImages = osImages[:topN] | ||
| } |
Comment on lines
+56
to
+60
| if gi, ok := byDigest[d]; ok { | ||
| groups[gi].indices = append(groups[gi].indices, i) | ||
| if tagSpecificity(images[i].Name) > tagSpecificity(images[groups[gi].primary].Name) { | ||
| groups[gi].primary = i | ||
| } |
Comment on lines
+116
to
+127
| // extractTag returns the tag portion of a full image name. | ||
| // E.g., "mcr.microsoft.com/repo:3.12-nonroot" → "3.12-nonroot" | ||
| func extractTag(name string) string { | ||
| lastSlash := strings.LastIndex(name, "/") | ||
| lastColon := strings.LastIndex(name, ":") | ||
|
|
||
| if lastColon > lastSlash { | ||
| return name[lastColon+1:] | ||
| } | ||
|
|
||
| return "" | ||
| } |
Comment on lines
+107
to
+116
| images, err := repo.QueryTopImagesByOS(lang, osName, 0) | ||
| if err != nil { | ||
| log.Warnf("Failed to query images for %s/%s: %v", lang, osName, err) | ||
| continue | ||
| } | ||
|
|
||
| images = DeduplicateByDigest(images) | ||
| if topN > 0 && len(images) > topN { | ||
| images = images[:topN] | ||
| } |
Comment on lines
118
to
136
| @@ -126,6 +132,7 @@ func GenerateJSONReport(repo *database.Repository, outputPath string, topN int, | |||
| PinnedReference: FormatPinnedReference(img.Name, img.Digest), | |||
| StableTag: FormatStableTag(img.Name, img.Version), | |||
| DockerfileFrom: FormatDockerfileFrom(img.Name, img.Digest), | |||
| AlternateTags: img.AlternateTags, | |||
| }) | |||
Comment on lines
+222
to
+224
| assert.Contains(t, content, "| Rank | Image | Version | Also Tagged As | Crit | High | Total | Size | Created | Digest | Pinned Reference |") | ||
| assert.Contains(t, content, "| 1 | `mcr.microsoft.com/azurelinux/base/python:3.12` | 3.12.1 | - | 0 | 1 | 5 | 81.1 MB | 2025-04-15 | `sha256:abcdef123456` |") | ||
| assert.Contains(t, content, "| 2 | `empty-created:latest` | - | - | 0 | 0 | 0 | - | - | `` | `-` |") |
…ntegration tests - Add lexicographic tie-break when tagSpecificity scores are equal - Sort AlternateTags for deterministic output across runs - Strip @sha256:... suffix in extractTag before parsing - Add JSON integration test verifying alternateTags field after dedup - Add markdown integration test verifying dedup row and Also Tagged As column Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment on lines
+32
to
+67
| // DeduplicateByDigest groups images sharing the same digest, keeping the most | ||
| // specific tag as the primary entry and collecting alternate tag names. | ||
| // Images with an empty digest are never grouped. | ||
| func DeduplicateByDigest(images []domain.RecommendedImage) []domain.RecommendedImage { | ||
| if len(images) <= 1 { | ||
| return images | ||
| } | ||
|
|
||
| type group struct { | ||
| primary int | ||
| indices []int | ||
| } | ||
|
|
||
| // Ordered list of groups preserving first-seen order. | ||
| var groups []group | ||
| // Maps non-empty digest to index in groups slice. | ||
| byDigest := make(map[string]int) | ||
|
|
||
| for i, img := range images { | ||
| d := img.Digest | ||
| if d == "" { | ||
| groups = append(groups, group{primary: i, indices: []int{i}}) | ||
| continue | ||
| } | ||
|
|
||
| if gi, ok := byDigest[d]; ok { | ||
| groups[gi].indices = append(groups[gi].indices, i) | ||
| newScore := tagSpecificity(images[i].Name) | ||
| oldScore := tagSpecificity(images[groups[gi].primary].Name) | ||
| if newScore > oldScore || (newScore == oldScore && images[i].Name < images[groups[gi].primary].Name) { | ||
| groups[gi].primary = i | ||
| } | ||
| } else { | ||
| byDigest[d] = len(groups) | ||
| groups = append(groups, group{primary: i, indices: []int{i}}) | ||
| } |
Comment on lines
+80
to
+90
| images, err := repo.QueryTopImagesByOS(lang, osName, 0) | ||
| if err != nil { | ||
| log.Warnf("Failed to query images for %s: %v", lang, err) | ||
| continue | ||
| } | ||
|
|
||
| images = DeduplicateByDigest(images) | ||
| if topN > 0 && len(images) > topN { | ||
| images = images[:topN] | ||
| } | ||
|
|
Comment on lines
+107
to
+115
| images, err := repo.QueryTopImagesByOS(lang, osName, 0) | ||
| if err != nil { | ||
| log.Warnf("Failed to query images for %s/%s: %v", lang, osName, err) | ||
| continue | ||
| } | ||
|
|
||
| images = DeduplicateByDigest(images) | ||
| if topN > 0 && len(images) > topN { | ||
| images = images[:topN] |
Group by (repository, digest) instead of digest alone, so images from different repositories that happen to share a digest are not merged. Adds imageRepo helper and cross-repo test coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment on lines
137
to
141
| // writeImageTable writes a ranked markdown table of images. | ||
| func writeImageTable(sb *strings.Builder, images []domain.RecommendedImage) { | ||
| sb.WriteString("| Rank | Image | Version | Crit | High | Total | Size | Created | Digest | Pinned Reference |\n") | ||
| sb.WriteString("|------|-------|---------|------|------|-------|------|---------|--------|------------------|\n") | ||
| sb.WriteString("| Rank | Image | Version | Also Tagged As | Crit | High | Total | Size | Created | Digest | Pinned Reference |\n") | ||
| sb.WriteString("|------|-------|---------|----------------|------|------|-------|------|---------|--------|------------------|\n") | ||
|
|
Comment on lines
+438
to
+442
| func TestGenerateMarkdownReport_DeduplicatesWithAlternateColumn(t *testing.T) { | ||
| db, repo := setupTestDB(t) | ||
| defer func() { _ = db.Close() }() | ||
|
|
||
| images := []domain.ImageRecord{ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Images with different tags but identical digests (e.g., nodejs:24 and nodejs:24.14) previously occupied multiple rows in the ranking tables. This PR deduplicates them at report generation time, showing the most specific tag as primary with alternates in an Also Tagged As column.
Changes
Design Decisions
Closes #35