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