diff --git a/go/internal/report/summary/collect.go b/go/internal/report/summary/collect.go
index 4b804b3..e41ab72 100644
--- a/go/internal/report/summary/collect.go
+++ b/go/internal/report/summary/collect.go
@@ -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
@@ -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] {
@@ -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),
})
}
}
@@ -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)
+ }
+ 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
diff --git a/go/internal/report/summary/markdown.go b/go/internal/report/summary/markdown.go
index 8a193c7..563dd08 100644
--- a/go/internal/report/summary/markdown.go
+++ b/go/internal/report/summary/markdown.go
@@ -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),
}
diff --git a/go/internal/report/summary/partial.go b/go/internal/report/summary/partial.go
index cae4676..c44c4e8 100644
--- a/go/internal/report/summary/partial.go
+++ b/go/internal/report/summary/partial.go
@@ -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)
diff --git a/go/internal/report/summary/pdf.go b/go/internal/report/summary/pdf.go
index d7d23f2..5040b0c 100644
--- a/go/internal/report/summary/pdf.go
+++ b/go/internal/report/summary/pdf.go
@@ -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.
@@ -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.
@@ -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
@@ -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, "")
}
@@ -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-
@@ -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
@@ -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 {
@@ -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 {
@@ -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.
@@ -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,
})
}
}
diff --git a/go/internal/report/summary/pdf_test.go b/go/internal/report/summary/pdf_test.go
index 81a544a..1d89f16 100644
--- a/go/internal/report/summary/pdf_test.go
+++ b/go/internal/report/summary/pdf_test.go
@@ -130,25 +130,104 @@ func TestDrawWrappedCell(t *testing.T) {
}
type fpdfCellCall struct {
- x, y, w, h float64
- text, border, align string
- ln int
- fill bool
+ x, y, w, h float64
+ text, border, align string
+ ln int
+ fill bool
+ // fontStyle/fontSize capture whatever SetFont call was in effect
+ // immediately before this CellFormat — set by fpdfCellRecorder's
+ // SetFont (used by the drawNameCell tests, #448).
+ fontStyle string
+ fontSize float64
}
type fpdfCellRecorder struct {
- x, y float64
- calls []fpdfCellCall
+ x, y float64
+ fontStyle string
+ fontSize float64
+ calls []fpdfCellCall
}
func (r *fpdfCellRecorder) GetXY() (float64, float64) { return r.x, r.y }
func (r *fpdfCellRecorder) SetXY(x, y float64) { r.x, r.y = x, y }
+func (r *fpdfCellRecorder) SetFont(familyStr, styleStr string, size float64) {
+ r.fontStyle, r.fontSize = styleStr, size
+}
func (r *fpdfCellRecorder) CellFormat(w, h float64, text, border string, ln int, align string, fill bool, link int, linkStr string) {
r.calls = append(r.calls, fpdfCellCall{
x: r.x, y: r.y, w: w, h: h,
text: text, border: border, align: align,
ln: ln, fill: fill,
+ fontStyle: r.fontStyle, fontSize: r.fontSize,
+ })
+}
+
+// TestDrawNameCell covers issue #448: the trailing keyLines must
+// render in the bold/smaller font while the leading nameLines stay
+// regular/nameFontSize, the border pattern must match drawWrappedCell
+// exactly (so the two are visually indistinguishable when there is no
+// key), and the caller's font must be restored afterward.
+func TestDrawNameCell(t *testing.T) {
+ rec := &fpdfCellRecorder{}
+ nameLines := [][]byte{[]byte("My Project")}
+ keyLines := [][]byte{[]byte("my-project-key")}
+ const nameFontSize, keyFontSize = 8.0, 6.0
+
+ drawNameCell(rec, 55, 4, len(nameLines)+len(keyLines), nameCellContent{
+ nameLines: nameLines, keyLines: keyLines,
+ nameFontSize: nameFontSize, keyFontSize: keyFontSize,
})
+
+ if len(rec.calls) != 2 {
+ t.Fatalf("expected 2 CellFormat calls, got %d", len(rec.calls))
+ }
+ nameCall, keyCall := rec.calls[0], rec.calls[1]
+ if nameCall.text != "My Project" || nameCall.fontStyle != "" || nameCall.fontSize != nameFontSize {
+ t.Errorf("name line: got text=%q style=%q size=%.1f, want text=%q style=%q size=%.1f",
+ nameCall.text, nameCall.fontStyle, nameCall.fontSize, "My Project", "", nameFontSize)
+ }
+ if keyCall.text != "my-project-key" || keyCall.fontStyle != "B" || keyCall.fontSize != keyFontSize {
+ t.Errorf("key line: got text=%q style=%q size=%.1f, want text=%q style=%q size=%.1f",
+ keyCall.text, keyCall.fontStyle, keyCall.fontSize, "my-project-key", "B", keyFontSize)
+ }
+ if nameCall.border != "LRT" || keyCall.border != "LRB" {
+ t.Errorf("borders: got %q/%q, want LRT/LRB", nameCall.border, keyCall.border)
+ }
+ // Font must be restored to regular/nameFontSize for the caller's
+ // next column.
+ if rec.fontStyle != "" || rec.fontSize != nameFontSize {
+ t.Errorf("font not restored: style=%q size=%.1f, want style=%q size=%.1f",
+ rec.fontStyle, rec.fontSize, "", nameFontSize)
+ }
+}
+
+// TestDrawNameCell_NoKeyMatchesDrawWrappedCell covers the every-other-
+// section case (#448): with no keyLines, drawNameCell's border/text
+// output for each line must be identical to drawWrappedCell's, and
+// every line renders in the regular font.
+func TestDrawNameCell_NoKeyMatchesDrawWrappedCell(t *testing.T) {
+ nameLines := [][]byte{[]byte("Quality Gate"), []byte("Long Name")}
+
+ wrapped := &fpdfCellRecorder{}
+ drawWrappedCell(wrapped, 55, 4, len(nameLines), nameLines)
+
+ named := &fpdfCellRecorder{}
+ drawNameCell(named, 55, 4, len(nameLines), nameCellContent{
+ nameLines: nameLines, nameFontSize: 8.0, keyFontSize: 8.0,
+ })
+
+ if len(named.calls) != len(wrapped.calls) {
+ t.Fatalf("call count: got %d, want %d", len(named.calls), len(wrapped.calls))
+ }
+ for i, nc := range named.calls {
+ wc := wrapped.calls[i]
+ if nc.text != wc.text || nc.border != wc.border {
+ t.Errorf("line %d: got text=%q border=%q, want text=%q border=%q", i, nc.text, nc.border, wc.text, wc.border)
+ }
+ if nc.fontStyle != "" {
+ t.Errorf("line %d: expected regular font with no key, got style=%q", i, nc.fontStyle)
+ }
+ }
}
func TestRenderPDFLongDetailsWrap(t *testing.T) {
@@ -412,6 +491,24 @@ func TestUnifiedRowDisplayNameWithLanguage(t *testing.T) {
}
}
+// TestUnifiedRowNameCellMarkdown covers issue #448: the Markdown Name
+// cell must append a line break plus the source key wrapped in
+// inline-bold markers (so mdDetail renders "**key**"), and must be a
+// pure passthrough to displayName() when there is no source key —
+// every non-Projects section.
+func TestUnifiedRowNameCellMarkdown(t *testing.T) {
+ withKey := unifiedRow{name: "Proj", sourceKey: "proj-key-1"}
+ want := "Proj\n" + inlineBoldStart + "proj-key-1" + inlineBoldEnd
+ if got := withKey.nameCellMarkdown(); got != want {
+ t.Errorf("nameCellMarkdown() = %q, want %q", got, want)
+ }
+
+ noKey := unifiedRow{name: "Gate"}
+ if got := noKey.nameCellMarkdown(); got != noKey.displayName() {
+ t.Errorf("nameCellMarkdown() without a key = %q, want displayName() %q", got, noKey.displayName())
+ }
+}
+
// #359: project-data migration outcome rendering. Successful imports
// are silent (no mention in Details). Skipped / failed projects say
// "Project data migration skipped" optionally followed by the reason.
diff --git a/go/internal/report/summary/report_448_test.go b/go/internal/report/summary/report_448_test.go
new file mode 100644
index 0000000..d10617a
--- /dev/null
+++ b/go/internal/report/summary/report_448_test.go
@@ -0,0 +1,170 @@
+// Copyright (C) SonarSource Sàrl
+// For more information, see https://sonarsource.com/legal/
+// mailto:info AT sonarsource DOT com
+
+package summary
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Issue #448: the Projects section's Name column shows the source
+// project key beneath the name. This file covers the data-plumbing
+// side — EntityItem.SourceKey must reach every bucket (Succeeded,
+// Skipped, Failed, Partial) that a project can land in.
+
+// TestCollectSummary_ProjectSourceKey_Succeeded covers the direct path:
+// createProjects JSONL carries the "key" field (via EnrichRaw
+// preserving generateProjectMappings' input fields), and
+// collectSucceeded must copy it into EntityItem.SourceKey.
+func TestCollectSummary_ProjectSourceKey_Succeeded(t *testing.T) {
+ dir := t.TempDir()
+ writeTaskJSONL(t, dir, "createProjects", []map[string]any{
+ {"key": "src-proj-1", "name": "Proj1", "sonarcloud_org_key": "org1", "cloud_project_key": "org1_proj1"},
+ })
+
+ summary, err := CollectSummary(dir, "")
+ if err != nil {
+ t.Fatalf("CollectSummary: %v", err)
+ }
+ projSection := findSection(summary, "Projects")
+ if projSection == nil || len(projSection.Succeeded) != 1 {
+ t.Fatalf("expected 1 succeeded project, got %+v", projSection)
+ }
+ if got := projSection.Succeeded[0].SourceKey; got != "src-proj-1" {
+ t.Errorf("SourceKey = %q, want %q", got, "src-proj-1")
+ }
+}
+
+// TestCollectSummary_ProjectSourceKey_Skipped covers the org-skipped
+// path: collectSkipped reads generateProjectMappings directly (a row
+// whose org mapping is empty/SKIPPED never reaches createProjects),
+// which also carries the "key" field.
+func TestCollectSummary_ProjectSourceKey_Skipped(t *testing.T) {
+ dir := t.TempDir()
+ writeTaskJSONL(t, dir, "generateProjectMappings", []map[string]any{
+ {"key": "src-proj-2", "name": "Proj2", "sonarcloud_org_key": "", "sonarqube_org_key": "sq-org"},
+ })
+
+ summary, err := CollectSummary(dir, "")
+ if err != nil {
+ t.Fatalf("CollectSummary: %v", err)
+ }
+ projSection := findSection(summary, "Projects")
+ if projSection == nil || len(projSection.Skipped) != 1 {
+ t.Fatalf("expected 1 skipped project, got %+v", projSection)
+ }
+ if got := projSection.Skipped[0].SourceKey; got != "src-proj-2" {
+ t.Errorf("SourceKey = %q, want %q", got, "src-proj-2")
+ }
+}
+
+// TestCollectSummary_ProjectSourceKey_Failed covers the harder path:
+// a Failed row's EntityItem comes from the analysis-report ledger
+// (analysis.ReportRow), which has no key field at all, so
+// attachFailedSourceKeys must look it up by Name against
+// generateProjectMappings.
+func TestCollectSummary_ProjectSourceKey_Failed(t *testing.T) {
+ dir := t.TempDir()
+ writeTaskJSONL(t, dir, "generateProjectMappings", []map[string]any{
+ {"key": "src-proj-3", "name": "FailProj", "sonarcloud_org_key": "org1"},
+ })
+
+ logEntry := map[string]any{
+ "process_type": "request_completed",
+ "status": "failure",
+ "payload": map[string]any{
+ "method": "POST",
+ "url": "/api/projects/create",
+ "status": float64(400),
+ "data": map[string]any{
+ "name": "FailProj",
+ "organization": "org1",
+ },
+ "response": `{"errors":[{"msg":"already exists"}]}`,
+ },
+ }
+ logBytes, _ := json.Marshal(logEntry)
+ if err := os.WriteFile(filepath.Join(dir, "requests.log"), logBytes, 0o644); err != nil {
+ t.Fatalf("write requests.log: %v", err)
+ }
+
+ summary, err := CollectSummary(dir, "")
+ if err != nil {
+ t.Fatalf("CollectSummary: %v", err)
+ }
+ projSection := findSection(summary, "Projects")
+ if projSection == nil || len(projSection.Failed) != 1 {
+ t.Fatalf("expected 1 failed project, got %+v", projSection)
+ }
+ if got := projSection.Failed[0].SourceKey; got != "src-proj-3" {
+ t.Errorf("SourceKey = %q, want %q", got, "src-proj-3")
+ }
+}
+
+// TestCollectPartial_CarriesSourceKey covers collectPartial's
+// carry-over path: a config-failure row that matches a Succeeded
+// entity by name must inherit that entity's SourceKey (mirroring the
+// existing Detail carry-over), so a project rerouted to Partial keeps
+// its source-key line in the report.
+func TestCollectPartial_CarriesSourceKey(t *testing.T) {
+ def := sectionDef{Name: "Projects"}
+ succeeded := []EntityItem{
+ {Name: "proj1", Detail: "cloud-1", SourceKey: "src-1"},
+ }
+ failures := []configFailure{
+ {Section: "Projects", Operation: "Project tags not migrated", EntityName: "proj1"},
+ }
+
+ partial := collectPartial(def, failures, succeeded)
+ if len(partial) != 1 {
+ t.Fatalf("expected 1 partial entry, got %d", len(partial))
+ }
+ if got := partial[0].SourceKey; got != "src-1" {
+ t.Errorf("SourceKey = %q, want %q", got, "src-1")
+ }
+}
+
+// TestRenderMarkdown_ProjectSourceKey covers the Markdown Name-cell
+// rendering end to end: a Projects row with a SourceKey must render
+// "Name
**KEY**" (name, then a line break, then the key in Markdown
+// bold — matching the Details column's target-project-key styling).
+// A row without a SourceKey (any other section) must render exactly
+// as before.
+func TestRenderMarkdown_ProjectSourceKey(t *testing.T) {
+ summary := &MigrationSummary{
+ RunID: "issue-448",
+ GeneratedAt: time.Now(),
+ Sections: []Section{
+ {
+ Name: "Projects",
+ Succeeded: []EntityItem{
+ {Name: "Proj1", Organization: "org1", Detail: "org1_proj1", SourceKey: "src-proj-1"},
+ },
+ },
+ {
+ Name: "Quality Gates",
+ Succeeded: []EntityItem{
+ {Name: "Gate1", Organization: "org1", Detail: "42"},
+ },
+ },
+ },
+ }
+
+ out, err := RenderMarkdown(summary)
+ if err != nil {
+ t.Fatalf("RenderMarkdown: %v", err)
+ }
+ md := string(out)
+ if !strings.Contains(md, "Proj1
**src-proj-1**") {
+ t.Errorf("expected Name cell 'Proj1
**src-proj-1**' in Markdown output, got:\n%s", md)
+ }
+ if strings.Contains(md, "Gate1
") || strings.Contains(md, "**42**") {
+ t.Errorf("Quality Gates row (no SourceKey) must render unchanged, got:\n%s", md)
+ }
+}
diff --git a/go/internal/report/summary/types.go b/go/internal/report/summary/types.go
index e3ddc26..c312329 100644
--- a/go/internal/report/summary/types.go
+++ b/go/internal/report/summary/types.go
@@ -241,6 +241,10 @@ type EntityItem struct {
ErrorMessage string // failures only
SkipReason string // for skipped items: SkipReason* constants below
Issues []string // for partial migrations: human-readable list of issues
+ // SourceKey is the source-side project key, shown beneath the project
+ // name in the report's Name column (issue #448). Populated for the
+ // Projects section only; empty for all other sections.
+ SourceKey string
}
// Skip reason constants used when classifying skipped entities.
@@ -278,6 +282,10 @@ type sectionDef struct {
NameField string // JSONL field to extract entity name
DetailField string // JSONL field for detail column (e.g., cloud key)
ExtractTask string // extract task for source data (empty if not applicable)
+ // SourceKeyField is the JSONL field holding the source-side key shown
+ // under the project name (issue #448). Only set for Projects; left
+ // empty elsewhere so other sections are unaffected.
+ SourceKeyField string
}
// sectionDefs defines the sections in report order.
@@ -331,6 +339,7 @@ var sectionDefs = []sectionDef{
AnalysisEntity: "Project",
NameField: "name",
DetailField: "cloud_project_key",
+ SourceKeyField: "key",
},
{
// Global settings (issue #186) — one row per non-default SQS