diff --git a/artifactory/utils/commandsummary/evidence_url.go b/artifactory/utils/commandsummary/evidence_url.go new file mode 100644 index 000000000..50ca88bca --- /dev/null +++ b/artifactory/utils/commandsummary/evidence_url.go @@ -0,0 +1,63 @@ +package commandsummary + +import ( + "fmt" + "net/url" +) + +const ( + releaseBundleEvidenceFormat = "%sui/artifactory/lifecycle?range=Any+Time&bundleName=%s&repositoryKey=%s&releaseBundleVersion=%s&activeVersionTab=Evidence+Graph" + buildEvidenceFormat = "%sui/builds/%s/%s/%s/Evidence/%s?buildRepo=%s" + artifactEvidenceFormat = "%sui/repos/tree/Evidence/%s?clearFilter=true" +) + +func GenerateEvidenceUrlByType(data EvidenceSummaryData, section summarySection) (string, error) { + switch data.SubjectType { + // Currently, it is not possible to generate a link to the evidence tab for packages in the Artifactory UI. + // The link will point to the lead artifact of the package instead. + // This logic will be updated once UI support is available + case SubjectTypePackage, SubjectTypeArtifact: + return generateArtifactEvidenceUrl(data.Subject, section) + case SubjectTypeReleaseBundle: + return generateReleaseBundleEvidenceUrl(data, section) + case SubjectTypeBuild: + return generateBuildEvidenceUrl(data, section) + default: + return generateArtifactEvidenceUrl(data.Subject, section) + } +} + +func generateArtifactEvidenceUrl(pathInRt string, section summarySection) (string, error) { + urlStr := fmt.Sprintf(artifactEvidenceFormat, StaticMarkdownConfig.GetPlatformUrl(), pathInRt) + return addGitHubTrackingToUrl(urlStr, section) +} + +func generateReleaseBundleEvidenceUrl(data EvidenceSummaryData, section summarySection) (string, error) { + if data.ReleaseBundleName == "" || data.ReleaseBundleVersion == "" { + return generateArtifactEvidenceUrl(data.Subject, section) + } + + urlStr := fmt.Sprintf(releaseBundleEvidenceFormat, + StaticMarkdownConfig.GetPlatformUrl(), + data.ReleaseBundleName, + data.RepoKey, + data.ReleaseBundleVersion) + + return addGitHubTrackingToUrl(urlStr, section) +} + +func generateBuildEvidenceUrl(data EvidenceSummaryData, section summarySection) (string, error) { + if data.BuildName == "" || data.BuildNumber == "" || data.BuildTimestamp == "" { + return generateArtifactEvidenceUrl(data.Subject, section) + } + + urlStr := fmt.Sprintf(buildEvidenceFormat, + StaticMarkdownConfig.GetPlatformUrl(), + url.QueryEscape(data.BuildName), + data.BuildNumber, + data.BuildTimestamp, + url.QueryEscape(data.BuildName), + data.RepoKey) + + return addGitHubTrackingToUrl(urlStr, section) +} diff --git a/artifactory/utils/commandsummary/evidence_url_test.go b/artifactory/utils/commandsummary/evidence_url_test.go new file mode 100644 index 000000000..352096985 --- /dev/null +++ b/artifactory/utils/commandsummary/evidence_url_test.go @@ -0,0 +1,141 @@ +package commandsummary + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateEvidenceUrlByType(t *testing.T) { + // Set up test environment + originalWorkflow := os.Getenv(workflowEnvKey) + err := os.Setenv(workflowEnvKey, "JFrog CLI Core Tests") + if err != nil { + assert.FailNow(t, "Failed to set environment variable", err) + } + defer func() { + if originalWorkflow != "" { + err = os.Setenv(workflowEnvKey, originalWorkflow) + if err != nil { + assert.Fail(t, "Failed to restore workflow environment variable", err) + return + } + } else { + os.Unsetenv(workflowEnvKey) + } + }() + + // Configure static markdown config for tests + StaticMarkdownConfig.setPlatformUrl("https://myplatform.com/") + StaticMarkdownConfig.setPlatformMajorVersion(7) + + tests := []struct { + name string + data EvidenceSummaryData + expectedURL string + expectError bool + }{ + { + name: "Package evidence URL", + data: EvidenceSummaryData{ + Subject: "repo/path/package.jar", + SubjectType: SubjectTypePackage, + }, + expectedURL: "https://myplatform.com/ui/repos/tree/Evidence/repo/path/package.jar?clearFilter=true&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1", + }, + { + name: "Artifact evidence URL", + data: EvidenceSummaryData{ + Subject: "repo/path/artifact.txt", + SubjectType: SubjectTypeArtifact, + }, + expectedURL: "https://myplatform.com/ui/repos/tree/Evidence/repo/path/artifact.txt?clearFilter=true&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1", + }, + { + name: "Release bundle evidence URL", + data: EvidenceSummaryData{ + Subject: "release-bundles-v2/my-bundle/1.0.0/release-bundle.json.evd", + SubjectType: SubjectTypeReleaseBundle, + ReleaseBundleName: "my-bundle", + ReleaseBundleVersion: "1.0.0", + RepoKey: "release-bundles-v2", + }, + expectedURL: "", // Will be checked with custom assertion + }, + { + name: "Build evidence URL", + data: EvidenceSummaryData{ + Subject: "artifactory-build-info/my-build/123/1234567890.json", + SubjectType: SubjectTypeBuild, + BuildName: "my-build", + BuildNumber: "123", + BuildTimestamp: "1234567890", + RepoKey: "artifactory-build-info", + }, + expectedURL: "https://myplatform.com/ui/builds/my-build/123/1234567890/Evidence/my-build?buildRepo=artifactory-build-info&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1", + }, + { + name: "Build with special characters in name", + data: EvidenceSummaryData{ + Subject: "artifactory-build-info/my build with spaces/123/1234567890.json", + SubjectType: SubjectTypeBuild, + BuildName: "my build with spaces", + BuildNumber: "123", + BuildTimestamp: "1234567890", + RepoKey: "artifactory-build-info", + }, + expectedURL: "https://myplatform.com/ui/builds/my+build+with+spaces/123/1234567890/Evidence/my+build+with+spaces?buildRepo=artifactory-build-info&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1", + }, + { + name: "Invalid release bundle falls back to artifact URL", + data: EvidenceSummaryData{ + Subject: "invalid/path", + SubjectType: SubjectTypeReleaseBundle, + }, + expectedURL: "https://myplatform.com/ui/repos/tree/Evidence/invalid/path?clearFilter=true&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1", + }, + { + name: "Invalid build falls back to artifact URL", + data: EvidenceSummaryData{ + Subject: "invalid/build/path", + SubjectType: SubjectTypeBuild, + }, + expectedURL: "https://myplatform.com/ui/repos/tree/Evidence/invalid/build/path?clearFilter=true&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1", + }, + { + name: "Default type uses artifact URL", + data: EvidenceSummaryData{ + Subject: "some/path/file.txt", + SubjectType: "", // Empty type should default to artifact + }, + expectedURL: "https://myplatform.com/ui/repos/tree/Evidence/some/path/file.txt?clearFilter=true&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url, err := GenerateEvidenceUrlByType(tt.data, evidenceSection) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + if tt.expectedURL != "" { + assert.Equal(t, tt.expectedURL, url) + } else if tt.name == "Release bundle evidence URL" { + // Special handling for release bundle URL due to parameter ordering + assert.Contains(t, url, "https://myplatform.com/ui/artifactory/lifecycle?") + assert.Contains(t, url, "bundleName=my-bundle") + assert.Contains(t, url, "repositoryKey=release-bundles-v2") + assert.Contains(t, url, "releaseBundleVersion=1.0.0") + assert.Contains(t, url, "activeVersionTab=Evidence+Graph") + assert.Contains(t, url, "gh_job_id=JFrog+CLI+Core+Tests") + assert.Contains(t, url, "gh_section=evidence") + assert.Contains(t, url, "m=3") + assert.Contains(t, url, "s=1") + assert.Contains(t, url, "range=Any+Time") + } + } + }) + } +} diff --git a/artifactory/utils/commandsummary/evidencesummary.go b/artifactory/utils/commandsummary/evidencesummary.go new file mode 100644 index 000000000..5e87d5cf7 --- /dev/null +++ b/artifactory/utils/commandsummary/evidencesummary.go @@ -0,0 +1,151 @@ +package commandsummary + +import ( + "fmt" + "github.com/jfrog/jfrog-client-go/utils/log" + "strings" + "time" +) + +const evidenceHeaderSize = 3 + +type EvidenceSummaryData struct { + Subject string `json:"subject"` + SubjectSha256 string `json:"subjectSha256"` + PredicateType string `json:"predicateType"` + PredicateSlug string `json:"predicateSlug"` + Verified bool `json:"verified"` + DisplayName string `json:"displayName,omitempty"` + SubjectType SubjectType `json:"subjectType"` + BuildName string `json:"buildName"` + BuildNumber string `json:"buildNumber"` + BuildTimestamp string `json:"buildTimestamp"` + ReleaseBundleName string `json:"releaseBundleName"` + ReleaseBundleVersion string `json:"releaseBundleVersion"` + RepoKey string `json:"repoKey"` + CreatedAt time.Time `json:"createdAt"` +} + +type SubjectType string + +const ( + SubjectTypeArtifact SubjectType = "artifact" + SubjectTypeBuild SubjectType = "build" + SubjectTypePackage SubjectType = "package" + SubjectTypeReleaseBundle SubjectType = "release-bundle" +) + +type EvidenceSummary struct { + CommandSummary +} + +func NewEvidenceSummary() (*CommandSummary, error) { + return New(&EvidenceSummary{}, "evidence") +} + +func (es *EvidenceSummary) GetSummaryTitle() string { + return "🔎 Evidence" +} + +func (es *EvidenceSummary) GenerateMarkdownFromFiles(dataFilePaths []string) (finalMarkdown string, err error) { + log.Debug("Generating evidence summary markdown.") + var evidenceData []EvidenceSummaryData + for _, filePath := range dataFilePaths { + var evidence EvidenceSummaryData + if err = UnmarshalFromFilePath(filePath, &evidence); err != nil { + log.Warn("Failed to unmarshal evidence data from file %s: %v", filePath, err) + return + } + evidenceData = append(evidenceData, evidence) + } + + if len(evidenceData) == 0 { + return + } + + tableMarkdown := es.generateEvidenceTable(evidenceData) + return WrapCollapsableMarkdown(es.GetSummaryTitle(), tableMarkdown, evidenceHeaderSize), nil +} + +func (es *EvidenceSummary) generateEvidenceTable(evidenceData []EvidenceSummaryData) string { + var tableBuilder strings.Builder + tableBuilder.WriteString(es.getTableHeader()) + + for _, evidence := range evidenceData { + es.appendEvidenceRow(&tableBuilder, evidence) + } + + tableBuilder.WriteString(" \n") + return tableBuilder.String() +} + +func (es *EvidenceSummary) getTableHeader() string { + return "\n" +} + +func (es *EvidenceSummary) appendEvidenceRow(tableBuilder *strings.Builder, evidence EvidenceSummaryData) { + subject := es.formatSubjectWithLink(evidence) + evidenceType := es.formatEvidenceType(evidence) + verificationStatus := es.formatVerificationStatus(evidence.Verified) + + tableBuilder.WriteString(fmt.Sprintf("\n", subject, evidenceType, verificationStatus)) +} + +func (es *EvidenceSummary) formatSubjectWithLink(evidence EvidenceSummaryData) string { + if evidence.Subject == "" { + return "evidence" + } + + evidenceUrl, err := GenerateEvidenceUrlByType(evidence, evidenceSection) + if err != nil { + log.Warn("Failed to generate evidence URL: %v", err) + evidenceUrl = "" + } + + displayName := evidence.DisplayName + if displayName == "" { + displayName = evidence.Subject + } + + var viewLink string + subjectType := es.formatSubjectType(evidence.SubjectType) + if evidenceUrl != "" { + viewLink = fmt.Sprintf(`%s %s`, subjectType, evidenceUrl, displayName) + } else { + viewLink = fmt.Sprintf("%s %s", subjectType, displayName) + } + + return viewLink +} + +func (es *EvidenceSummary) formatVerificationStatus(verified bool) string { + if verified { + return fmt.Sprintf("%s Verified", "✅") + } + return fmt.Sprintf("%s Not Verified", "❌") +} + +func (es *EvidenceSummary) formatEvidenceType(evidence EvidenceSummaryData) string { + if evidence.PredicateSlug == "" { + if evidence.PredicateType == "" { + return "⚠️ Unknown" + } + return evidence.PredicateType + } + return evidence.PredicateSlug +} + +func (es *EvidenceSummary) formatSubjectType(subjectType SubjectType) string { + switch subjectType { + case SubjectTypePackage: + return "📦️" + case SubjectTypeBuild: + return "🛠️️" + case SubjectTypeReleaseBundle: + return "🧩" + case SubjectTypeArtifact: + return "📄" + default: + return "" + } +} diff --git a/artifactory/utils/commandsummary/evidencesummary_test.go b/artifactory/utils/commandsummary/evidencesummary_test.go new file mode 100644 index 000000000..e69356b8f --- /dev/null +++ b/artifactory/utils/commandsummary/evidencesummary_test.go @@ -0,0 +1,191 @@ +package commandsummary + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + evidenceTable = "evidence.md" +) + +func prepareEvidenceTest(t *testing.T) (*EvidenceSummary, func()) { + originalWorkflow := os.Getenv(workflowEnvKey) + err := os.Setenv(workflowEnvKey, "JFrog CLI Core Tests") + if err != nil { + assert.Fail(t, "Failed to set environment variable", err) + } + + StaticMarkdownConfig.setPlatformUrl(testPlatformUrl) + StaticMarkdownConfig.setPlatformMajorVersion(7) + StaticMarkdownConfig.setExtendedSummary(false) + + cleanup := func() { + StaticMarkdownConfig.setExtendedSummary(false) + StaticMarkdownConfig.setPlatformMajorVersion(0) + StaticMarkdownConfig.setPlatformUrl("") + + if originalWorkflow != "" { + err = os.Setenv(workflowEnvKey, originalWorkflow) + if err != nil { + assert.Fail(t, "Failed to set workflow environment variable", err) + } + } else { + os.Unsetenv(workflowEnvKey) + } + } + + evidenceSummary := &EvidenceSummary{} + return evidenceSummary, cleanup +} + +func TestEvidenceTable(t *testing.T) { + evidenceSummary, cleanUp := prepareEvidenceTest(t) + defer func() { + cleanUp() + }() + + createdTime := time.Date(2024, 12, 1, 10, 0, 0, 0, time.UTC) + + var evidenceData = []EvidenceSummaryData{ + { + Subject: "cli-sigstore-test/commons-1.0.0.txt", + SubjectSha256: "", + PredicateType: "in-toto", + PredicateSlug: "in-toto", + Verified: true, + DisplayName: "cli-sigstore-test/commons-1.0.0.txt", + SubjectType: SubjectTypeArtifact, + RepoKey: "cli-sigstore-test/commons-1.0.0.txt", + CreatedAt: createdTime, + }, + } + + t.Run("Extended Summary", func(t *testing.T) { + StaticMarkdownConfig.setExtendedSummary(true) + res := evidenceSummary.generateEvidenceTable(evidenceData) + testMarkdownOutput(t, getTestDataFile(t, evidenceTable), res) + }) + + t.Run("Basic Summary", func(t *testing.T) { + StaticMarkdownConfig.setExtendedSummary(false) + res := evidenceSummary.generateEvidenceTable(evidenceData) + testMarkdownOutput(t, getTestDataFile(t, evidenceTable), res) + }) +} + +func TestFormatSubjectType(t *testing.T) { + evidenceSummary := &EvidenceSummary{} + + tests := []struct { + name string + subjectType SubjectType + expectedIcon string + }{ + { + name: "Artifact", + subjectType: SubjectTypeArtifact, + expectedIcon: "📄", + }, + { + name: "Package", + subjectType: SubjectTypePackage, + expectedIcon: "📦️", + }, + { + name: "Build", + subjectType: SubjectTypeBuild, + expectedIcon: "🛠️️", + }, + { + name: "Release Bundle", + subjectType: SubjectTypeReleaseBundle, + expectedIcon: "🧩", + }, + { + name: "Unknown", + subjectType: SubjectType("unknown"), + expectedIcon: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := evidenceSummary.formatSubjectType(tt.subjectType) + assert.Equal(t, tt.expectedIcon, result) + }) + } +} + +func TestFormatVerificationStatus(t *testing.T) { + evidenceSummary := &EvidenceSummary{} + + tests := []struct { + name string + verified bool + expected string + }{ + { + name: "Verified", + verified: true, + expected: "✅ Verified", + }, + { + name: "Not Verified", + verified: false, + expected: "❌ Not Verified", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := evidenceSummary.formatVerificationStatus(tt.verified) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatEvidenceType(t *testing.T) { + evidenceSummary := &EvidenceSummary{} + + tests := []struct { + name string + evidence EvidenceSummaryData + expectedType string + }{ + { + name: "With PredicateSlug", + evidence: EvidenceSummaryData{ + PredicateSlug: "in-toto", + PredicateType: "in-toto", + }, + expectedType: "in-toto", + }, + { + name: "Without PredicateSlug but with PredicateType", + evidence: EvidenceSummaryData{ + PredicateSlug: "", + PredicateType: "https://slsa.dev/provenance/v0.2", + }, + expectedType: "https://slsa.dev/provenance/v0.2", + }, + { + name: "Without PredicateSlug and PredicateType", + evidence: EvidenceSummaryData{ + PredicateSlug: "", + PredicateType: "", + }, + expectedType: "⚠️ Unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := evidenceSummary.formatEvidenceType(tt.evidence) + assert.Equal(t, tt.expectedType, result) + }) + } +} diff --git a/artifactory/utils/commandsummary/utils.go b/artifactory/utils/commandsummary/utils.go index 75b9541e9..a83457c0f 100644 --- a/artifactory/utils/commandsummary/utils.go +++ b/artifactory/utils/commandsummary/utils.go @@ -47,6 +47,7 @@ const ( artifactsSection summarySection = "artifacts" packagesSection summarySection = "packages" buildInfoSection summarySection = "buildInfo" + evidenceSection summarySection = "evidence" ) const ( diff --git a/artifactory/utils/testdata/command_summaries/basic/evidence.md b/artifactory/utils/testdata/command_summaries/basic/evidence.md new file mode 100644 index 000000000..b12858ec1 --- /dev/null +++ b/artifactory/utils/testdata/command_summaries/basic/evidence.md @@ -0,0 +1,3 @@ +
Evidence SubjectEvidence TypeVerification Status
%s%s%s
+ +
Evidence SubjectEvidence TypeVerification Status
📄 cli-sigstore-test/commons-1.0.0.txtin-toto✅ Verified
diff --git a/artifactory/utils/testdata/command_summaries/extended/evidence.md b/artifactory/utils/testdata/command_summaries/extended/evidence.md new file mode 100644 index 000000000..b12858ec1 --- /dev/null +++ b/artifactory/utils/testdata/command_summaries/extended/evidence.md @@ -0,0 +1,3 @@ + + +
Evidence SubjectEvidence TypeVerification Status
📄 cli-sigstore-test/commons-1.0.0.txtin-toto✅ Verified