Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions go/internal/report/summary/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ func collectSection(store *common.DataStore, def sectionDef,
succeeded := collectSucceeded(store, def)
skipped := collectSkipped(store, def)
failed := collectFailed(failuresByType, def)
attachFailedSourceKeys(failed, store, def)
partial := collectPartial(def, configFailures, succeeded)
var nearPerfect []EntityItem

Expand Down Expand Up @@ -669,6 +670,7 @@ func collectSucceeded(store *common.DataStore, def sectionDef) []EntityItem {
Language: jsonStr(item, "language"),
Organization: jsonStr(item, "sonarcloud_org_key"),
Detail: jsonStr(item, def.DetailField),
SourceKey: jsonStr(item, def.SourceKeyField),
}
key := entry.Organization + "\x00" + entry.Detail + "\x00" + entry.Name + "\x00" + entry.Language
if seen[key] {
Expand Down Expand Up @@ -704,6 +706,7 @@ func collectSkipped(store *common.DataStore, def sectionDef) []EntityItem {
Organization: jsonStr(item, "sonarqube_org_key"),
Detail: "Organization skipped",
SkipReason: SkipReasonOrgSkipped,
SourceKey: jsonStr(item, def.SourceKeyField),
})
}
}
Expand Down Expand Up @@ -809,6 +812,36 @@ func collectFailed(failuresByType map[string][]analysis.ReportRow, def sectionDe
return result
}

// attachFailedSourceKeys fills in EntityItem.SourceKey for Failed-bucket
// rows (issue #448) by matching on Name against the generate*Mappings task
// output. Failed rows come from the analysis report ledger
// (analysis.ReportRow), which carries the entity name but not the source
// key, so — unlike Succeeded/Skipped — it has to be looked up separately.
// No-op when the section has no SourceKeyField (all sections except
// Projects).
func attachFailedSourceKeys(items []EntityItem, store *common.DataStore, def sectionDef) {
if def.SourceKeyField == "" || len(items) == 0 {
return
}
mapped, err := store.ReadAll(def.InputTask)
if err != nil {
return
}
keyByName := make(map[string]string, len(mapped))
for _, item := range mapped {
name := jsonStr(item, def.NameField)
if name == "" {
continue
}
keyByName[name] = jsonStr(item, def.SourceKeyField)
Comment on lines +822 to +836

@gitar-bot gitar-bot Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Failed source-key lookup collides on duplicate project names

attachFailedSourceKeys builds keyByName keyed solely on the project name (def.NameField) and then assigns items[i].SourceKey by name match. Unlike the Succeeded/Skipped paths (which read the source key directly from the same record), Failed rows are matched purely by name. Project names are not guaranteed unique — the same name can exist across different organizations, or even be reused — so the map silently overwrites earlier entries (last-write-wins). When two projects share a name but have different source keys, a failed row can be annotated with the wrong source key, which is misleading in a migration audit report. Consider disambiguating the lookup by including the organization (the analysis ReportRow carries Organization, and generateProjectMappings rows carry sonarcloud_org_key/sonarqube_org_key), or otherwise handling the collision case rather than blindly overwriting.

Key the lookup by organization + name so projects that share a name across organizations are not conflated.:

keyByName := make(map[string]string, len(mapped))
for _, item := range mapped {
	name := jsonStr(item, def.NameField)
	if name == "" {
		continue
	}
	org := jsonStr(item, "sonarcloud_org_key")
	keyByName[org+"\x00"+name] = jsonStr(item, def.SourceKeyField)
}
for i := range items {
	if key, ok := keyByName[items[i].Organization+"\x00"+items[i].Name]; ok {
		items[i].SourceKey = key
	}
}

Was this helpful? React with 👍 / 👎

}
for i := range items {
if key, ok := keyByName[items[i].Name]; ok {
items[i].SourceKey = key
}
}
}

// projectDataOutcome holds the per-project state of the
// importProjectData task plus a human-readable reason for the skipped /
// failed cases (#359). It replaces the older single-status string that
Expand Down
2 changes: 1 addition & 1 deletion go/internal/report/summary/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func renderMarkdownSections(sb *strings.Builder, summary *MigrationSummary) {
mdRows := make([]map[string]any, 0, len(rows))
for _, r := range rows {
row := map[string]any{
"name": mdCell(r.displayName()),
"name": mdDetail(r.nameCellMarkdown()),
"outcome": r.outcome,
"details": mdDetail(r.details),
}
Expand Down
4 changes: 3 additions & 1 deletion go/internal/report/summary/partial.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,11 @@ func collectPartial(def sectionDef, failures []configFailure, succeeded []Entity
Organization: cf.Organization,
Issues: []string{issue},
}
// Carry over detail (cloud_id) from the matching success entry, if any.
// Carry over detail (cloud_id) and source key from the matching
// success entry, if any (#448 for SourceKey).
if sIdx, ok := succeededByName[cf.EntityName]; ok {
item.Detail = succeeded[sIdx].Detail
item.SourceKey = succeeded[sIdx].SourceKey
}
merged[cf.EntityName] = len(partial)
partial = append(partial, item)
Expand Down
187 changes: 148 additions & 39 deletions go/internal/report/summary/pdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,10 @@ type unifiedRow struct {
outcome string
color [3]int
details string
// sourceKey is the source-side project key (issue #448), rendered
// beneath the name in small bold text. Populated for Projects rows
// only; empty for every other section.
sourceKey string
}

// displayName returns the row's name as displayed in the Name column.
Expand All @@ -510,6 +514,19 @@ func (r unifiedRow) displayName() string {
return r.name
}

// nameCellMarkdown returns the Markdown Name-cell value: displayName(),
// plus — when a source key is present — a line break and the key wrapped
// in inline-bold markers, so mdDetail renders it as "**key**" below the
// name (issue #448), matching the Details column's target-project-key
// styling.
func (r unifiedRow) nameCellMarkdown() string {
name := r.displayName()
if r.sourceKey == "" {
return name
}
return name + "\n" + inlineBoldStart + r.sourceKey + inlineBoldEnd
}

// sectionsWithoutOrganization lists sections rendered without an Organization
// column. Portfolios are created at the enterprise level, so an organization
// dimension is not meaningful.
Expand Down Expand Up @@ -635,13 +652,7 @@ func renderUnifiedTable(pdf *fpdf.Fpdf, section Section, predictive bool) {
// directly without going through truncate(), which was the
// previous sanitizer entry point for the Name column.
nameText := sanitizeForPDF(row.displayName())
nameLineCount := 1
if wrapName {
nameLineCount = len(pdf.SplitLines([]byte(nameText), widths[0]))
if nameLineCount < 1 {
nameLineCount = 1
}
}
nameLines, keyLines, nameLineCount := computeNameCellLines(pdf, row, nameText, widths[0], bodyFontSize, multiLineFontSize, wrapName)
lineCount := detailsLineCount
if nameLineCount > lineCount {
lineCount = nameLineCount
Expand Down Expand Up @@ -686,8 +697,13 @@ func renderUnifiedTable(pdf *fpdf.Fpdf, section Section, predictive bool) {
// clipped descenders when nameLineCount matched (the per-
// line height of 3mm was too tight for 8pt body). Drawing
// one cell per line at the row's lineH gives consistent
// height AND room for descenders.
drawWrappedCell(pdf, widths[col], lineH, lineCount, pdf.SplitLines([]byte(nameText), widths[col]))
// height AND room for descenders. drawNameCell additionally
// renders any trailing keyLines (the source project key,
// #448) in a smaller bold font beneath the name lines.
drawNameCell(pdf, widths[col], lineH, lineCount, nameCellContent{
nameLines: nameLines, keyLines: keyLines,
nameFontSize: bodyFontSize, keyFontSize: multiLineFontSize,
})
} else {
pdf.CellFormat(widths[col], rowHeight, truncate(nameText, nameLimit), "1", 0, "L", true, 0, "")
}
Expand Down Expand Up @@ -724,6 +740,34 @@ func renderUnifiedTable(pdf *fpdf.Fpdf, section Section, predictive bool) {
}
}

// computeNameCellLines wraps a row's name text (and, when wrapName is
// true and the row carries a source project key, the key text too —
// issue #448) to colWidth, returning the two line groups plus their
// combined count for row-height sizing. The key is measured in the
// bold/smaller key font, restoring bodyFontSize before returning.
// Pulled out of renderUnifiedTable's per-row loop to keep that
// function's branching from growing with the #448 addition.
func computeNameCellLines(pdf *fpdf.Fpdf, row unifiedRow, nameText string, colWidth, bodyFontSize, keyFontSize float64, wrapName bool) (nameLines, keyLines [][]byte, lineCount int) {
if !wrapName {
return nil, nil, 1
}
nameLines = pdf.SplitLines([]byte(nameText), colWidth)
lineCount = len(nameLines)
if lineCount < 1 {
lineCount = 1
}
if row.sourceKey == "" {
return nameLines, nil, lineCount
}
// Source project key: rendered on its own line(s) beneath the
// name, in the same smaller bold styling the Details column uses
// for the target project key.
pdf.SetFont(pdfFontFamilyBody, "B", keyFontSize)
keyLines = pdf.SplitLines([]byte(sanitizeForPDF(row.sourceKey)), colWidth)
pdf.SetFont(pdfFontFamilyBody, "", bodyFontSize)
return nameLines, keyLines, lineCount + len(keyLines)
}

// drawDetailsCell renders the Details column for a unified-table row.
// It always wraps via wrapInlineBoldLines — that path's width-aware
// wrap (#302) handles both inline-bold spans AND the hard-break-at-
Expand Down Expand Up @@ -978,6 +1022,66 @@ type fpdfCell interface {
CellFormat(w, h float64, txtStr, borderStr string, ln int, alignStr string, fill bool, link int, linkStr string)
}

// fpdfStyledCell is fpdfCell plus SetFont, letting drawNameCell switch
// font style/size per line. *fpdf.Fpdf satisfies this implicitly; tests
// can supply a recorder instead of spinning up a full PDF document.
type fpdfStyledCell interface {
fpdfCell
SetFont(familyStr, styleStr string, size float64)
}

// nameCellContent groups the Name column's two font-styled line groups —
// the regular-font name lines and the trailing bold/smaller source-key
// lines (issue #448) — into one value so drawNameCell stays within the
// project's 7-parameter limit.
type nameCellContent struct {
nameLines, keyLines [][]byte
nameFontSize, keyFontSize float64
}

// drawNameCell renders the Name column as one CellFormat per physical
// line, like drawWrappedCell, but content.keyLines render in a smaller
// bold font beneath the regular nameLines — the source project key
// line (issue #448), matching the target project key's bold styling in
// the Details column (drawDetailsCell). Restores the caller's font
// (regular, nameFontSize) before returning.
func drawNameCell(pdf fpdfStyledCell, w, lineH float64, lineCount int, content nameCellContent) {
x, y := pdf.GetXY()
for j := 0; j < lineCount; j++ {
var text string
bold := false
fontSize := content.nameFontSize
switch {
case j < len(content.nameLines):
text = string(content.nameLines[j])
case j-len(content.nameLines) < len(content.keyLines):
text = string(content.keyLines[j-len(content.nameLines)])
bold = true
fontSize = content.keyFontSize
}
border := ""
switch {
case lineCount == 1:
border = "1"
case j == 0:
border = "LRT"
case j == lineCount-1:
border = "LRB"
default:
border = "LR"
}
style := ""
if bold {
style = "B"
}
pdf.SetFont(pdfFontFamilyBody, style, fontSize)
pdf.SetXY(x, y+float64(j)*lineH)
pdf.CellFormat(w, lineH, text, border, 0, "L", true, 0, "")
}
pdf.SetXY(x+w, y)
pdf.SetFont(pdfFontFamilyBody, "", content.nameFontSize)
}

// buildUnifiedRows flattens the section's buckets into ordered rows:
// Succeeded → NearPerfect → Partial → Failed → Skipped (skipped grouped by
// reason order). Order mirrors the green → yellow → orange → red → grey
Expand Down Expand Up @@ -1011,12 +1115,13 @@ func buildUnifiedRows(section Section, predictive bool) []unifiedRow {

for _, item := range section.Succeeded {
rows = append(rows, unifiedRow{
name: item.Name,
language: item.Language,
org: item.Organization,
outcome: successLabel,
color: colorGreen,
details: toPredictiveTense(successDetails(item, predictive, hideCloudKey, labelProjectKey), predictive),
name: item.Name,
language: item.Language,
org: item.Organization,
outcome: successLabel,
color: colorGreen,
details: toPredictiveTense(successDetails(item, predictive, hideCloudKey, labelProjectKey), predictive),
sourceKey: item.SourceKey,
})
}
for _, item := range section.NearPerfect {
Expand All @@ -1025,12 +1130,13 @@ func buildUnifiedRows(section Section, predictive bool) []unifiedRow {
name = "(unknown)"
}
rows = append(rows, unifiedRow{
name: name,
language: item.Language,
org: item.Organization,
outcome: outcomeNearPerfect,
color: colorYellow,
details: toPredictiveTense(partialDetails(item, predictive, hideCloudKey, labelProjectKey), predictive),
name: name,
language: item.Language,
org: item.Organization,
outcome: outcomeNearPerfect,
color: colorYellow,
details: toPredictiveTense(partialDetails(item, predictive, hideCloudKey, labelProjectKey), predictive),
sourceKey: item.SourceKey,
})
}
for _, item := range section.Partial {
Expand All @@ -1039,22 +1145,24 @@ func buildUnifiedRows(section Section, predictive bool) []unifiedRow {
name = "(unknown)"
}
rows = append(rows, unifiedRow{
name: name,
language: item.Language,
org: item.Organization,
outcome: outcomePartial,
color: colorAmber,
details: toPredictiveTense(partialDetails(item, predictive, hideCloudKey, labelProjectKey), predictive),
name: name,
language: item.Language,
org: item.Organization,
outcome: outcomePartial,
color: colorAmber,
details: toPredictiveTense(partialDetails(item, predictive, hideCloudKey, labelProjectKey), predictive),
sourceKey: item.SourceKey,
})
}
for _, item := range section.Failed {
rows = append(rows, unifiedRow{
name: item.Name,
language: item.Language,
org: item.Organization,
outcome: outcomeFailed,
color: colorRed,
details: toPredictiveTense(item.ErrorMessage, predictive),
name: item.Name,
language: item.Language,
org: item.Organization,
outcome: outcomeFailed,
color: colorRed,
details: toPredictiveTense(item.ErrorMessage, predictive),
sourceKey: item.SourceKey,
})
}
// Skipped — preserve group ordering by SkipReason.
Expand All @@ -1065,12 +1173,13 @@ func buildUnifiedRows(section Section, predictive bool) []unifiedRow {
for _, entry := range skipReasonOrder {
for _, item := range skippedGroups[entry.Reason] {
rows = append(rows, unifiedRow{
name: item.Name,
language: item.Language,
org: item.Organization,
outcome: outcomeSkipped,
color: colorDarkGray,
details: toPredictiveTense(skippedDetails(item), predictive),
name: item.Name,
language: item.Language,
org: item.Organization,
outcome: outcomeSkipped,
color: colorDarkGray,
details: toPredictiveTense(skippedDetails(item), predictive),
sourceKey: item.SourceKey,
})
}
}
Expand Down
Loading
Loading