From f80a25570629208ef44fa37f9a1a796955835491 Mon Sep 17 00:00:00 2001 From: Olivier Korach Date: Fri, 10 Jul 2026 14:59:22 +0200 Subject: [PATCH] Show source project key beneath the name in migration reports Only the source project name was shown in the Projects section's Name column, making it hard to identify projects that share a name across servers/orgs. Adds the source key on its own line, styled like the target key already is in the Details column, in both the actual and predictive PDF/Markdown reports. Fixes #448 Co-Authored-By: Claude Sonnet 5 --- go/internal/report/summary/collect.go | 33 ++++ go/internal/report/summary/markdown.go | 2 +- go/internal/report/summary/partial.go | 4 +- go/internal/report/summary/pdf.go | 187 ++++++++++++++---- go/internal/report/summary/pdf_test.go | 109 +++++++++- go/internal/report/summary/report_448_test.go | 170 ++++++++++++++++ go/internal/report/summary/types.go | 9 + 7 files changed, 467 insertions(+), 47 deletions(-) create mode 100644 go/internal/report/summary/report_448_test.go 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