diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index 37f786271..19299af14 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -6,6 +6,8 @@ import ( "io" "net/http" "net/url" + "sort" + "strings" "github.com/kosli-dev/cli/internal/output" "github.com/kosli-dev/cli/internal/requests" @@ -39,7 +41,7 @@ func newGetTrailCmd(out io.Writer) *cobra.Command { } cmd.Flags().StringVarP(&o.flowName, "flow", "f", "", flowNameFlag) - cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlagWithMarkdown) err := RequireFlags(cmd, []string{"flow"}) if err != nil { @@ -67,11 +69,281 @@ func (o *getTrailOptions) run(out io.Writer, args []string) error { return output.FormattedPrint(response.Body, o.output, out, 0, map[string]output.FormatOutputFunc{ - "table": printTrailAsTable, - "json": output.PrintJson, + "table": printTrailAsTable, + "json": output.PrintJson, + "markdown": o.printTrailAsMarkdown, }) } +// printTrailAsMarkdown renders a trail as GitHub-Flavored Markdown, suitable for +// piping into a CI job summary (e.g. GitHub's $GITHUB_STEP_SUMMARY or a GitLab +// summary.md artifact). It is a method so the trail heading can link to the +// trail page in the Kosli app, which needs the flow name. +func (o *getTrailOptions) printTrailAsMarkdown(raw string, out io.Writer, page int) error { + var trail map[string]interface{} + err := json.Unmarshal([]byte(raw), &trail) + if err != nil { + return err + } + + lastModifiedAt, err := formattedTimestamp(trail["last_modified_at"], false) + if err != nil { + return err + } + + var b strings.Builder + trailURL, err := url.JoinPath(global.Host, global.Org, "flows", o.flowName, "trails", fmt.Sprintf("%v", trail["name"])) + if err != nil { + trailURL = "" + } + heading := mdCell(trail["name"]) + if trailURL != "" { + heading = fmt.Sprintf("[%s](%s)", heading, trailURL) + } + fmt.Fprintf(&b, "## Trail: %s\n\n", heading) + b.WriteString("| | |\n") + b.WriteString("| --- | --- |\n") + fmt.Fprintf(&b, "| Name | %s |\n", mdCell(trail["name"])) + fmt.Fprintf(&b, "| Description | %s |\n", mdCell(trail["description"])) + fmt.Fprintf(&b, "| Compliance | %s |\n", mdComplianceState(trail["compliance_state"])) + fmt.Fprintf(&b, "| Last modified at | %s |\n", mdCell(lastModifiedAt)) + if originURL, ok := trail["origin_url"].(string); ok && originURL != "" { + fmt.Fprintf(&b, "| Origin | %s |\n", mdCell(originURL)) + } + + if commitInfo, ok := trail["git_commit_info"].(map[string]interface{}); ok { + commitTimestamp, err := formattedTimestamp(commitInfo["timestamp"], false) + if err != nil { + return err + } + sha := mdCell(commitInfo["sha1"]) + if commitURL, ok := commitInfo["url"].(string); ok && commitURL != "" { + sha = fmt.Sprintf("[%s](%s)", sha, commitURL) + } + b.WriteString("\n### Git commit\n\n") + b.WriteString("| | |\n") + b.WriteString("| --- | --- |\n") + fmt.Fprintf(&b, "| Sha1 | %s |\n", sha) + fmt.Fprintf(&b, "| Author | %s |\n", mdCell(commitInfo["author"])) + fmt.Fprintf(&b, "| Timestamp | %s |\n", mdCell(commitTimestamp)) + fmt.Fprintf(&b, "| Message | %s |\n", mdCell(firstLine(commitInfo["message"]))) + } + + writeAttestationStatuses(&b, trail["compliance_status"], trailURL) + + b.WriteString("\n### Events\n\n") + if events, ok := trail["events"].([]interface{}); ok && len(events) > 0 { + b.WriteString("| Time | Description | Git commit | Compliance |\n") + b.WriteString("| --- | --- | --- | --- |\n") + for _, event := range events { + e, err := eventFields(event) + if err != nil { + return err + } + commit := mdCell(e.commitSHA) + if commit != "" && e.commitURL != "" { + commit = fmt.Sprintf("[%s](%s)", commit, e.commitURL) + } + fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", + mdCell(e.timestamp), mdEventDescription(e, trailURL), commit, mdEventCompliance(e.compliance)) + } + } else { + b.WriteString("_No events._\n") + } + + _, err = fmt.Fprint(out, b.String()) + return err +} + +// mdCell renders a value as a single markdown table cell, escaping characters +// that would otherwise break the table layout or be swallowed as HTML (e.g. +// "" in a commit author). CR and CRLF count as line endings in +// CommonMark, so they must be normalized along with LF. +func mdCell(v interface{}) string { + if v == nil { + return "" + } + s := fmt.Sprintf("%v", v) + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "|", "\\|") + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + s = strings.ReplaceAll(s, "\n", "
") + return s +} + +// firstLine returns the first line of a multi-line value, e.g. a git commit +// message subject. A full commit message would dominate a CI summary table. +func firstLine(v interface{}) string { + if v == nil { + return "" + } + s := fmt.Sprintf("%v", v) + if i := strings.IndexAny(s, "\r\n"); i >= 0 { + return s[:i] + } + return s +} + +// mdComplianceState prefixes a trail or artifact compliance state with a +// glanceable emoji. Values come from the server: COMPLIANT / NON-COMPLIANT / +// INCOMPLETE for trails, plus MISSING for artifacts. +func mdComplianceState(v interface{}) string { + s := mdCell(v) + switch s { + case "COMPLIANT": + return "✅ " + s + case "NON_COMPLIANT", "NON-COMPLIANT": + return "❌ " + s + case "INCOMPLETE", "MISSING": + return "⏳ " + s + default: + return s + } +} + +// writeAttestationStatuses renders the trail's attestation compliance statuses +// as headerless two-column tables (attestation name → compliance), grouped by +// the trail and by each artifact. The attestation name links to the attestation +// on the trail page when an attestation_id is present. The section is omitted +// when the trail has no attestation statuses. +func writeAttestationStatuses(b *strings.Builder, complianceStatus interface{}, trailURL string) { + cs, ok := complianceStatus.(map[string]interface{}) + if !ok { + return + } + trailAtts, _ := cs["attestations_statuses"].([]interface{}) + artifactsStatuses, _ := cs["artifacts_statuses"].(map[string]interface{}) + + artifactNames := make([]string, 0, len(artifactsStatuses)) + for name := range artifactsStatuses { + artifactNames = append(artifactNames, name) + } + sort.Strings(artifactNames) + + total := len(trailAtts) + for _, name := range artifactNames { + if artifact, ok := artifactsStatuses[name].(map[string]interface{}); ok { + if atts, ok := artifact["attestations_statuses"].([]interface{}); ok { + total += len(atts) + } + } + } + if total == 0 { + return + } + + b.WriteString("\n### Attestations\n") + + if len(trailAtts) > 0 { + b.WriteString("\n**Trail**\n\n") + writeAttestationTable(b, trailAtts, trailURL) + } + + for _, name := range artifactNames { + artifact, ok := artifactsStatuses[name].(map[string]interface{}) + if !ok { + continue + } + fmt.Fprintf(b, "\n**%s** — %s\n\n", mdCell(name), mdComplianceState(artifact["status"])) + atts, _ := artifact["attestations_statuses"].([]interface{}) + writeAttestationTable(b, atts, trailURL) + } +} + +// writeAttestationTable writes a headerless two-column table of attestation +// name (linked when possible) and compliance status. +func writeAttestationTable(b *strings.Builder, attestations []interface{}, trailURL string) { + b.WriteString("| | |\n") + b.WriteString("| --- | --- |\n") + for _, a := range attestations { + att, ok := a.(map[string]interface{}) + if !ok { + continue + } + name := mdCell(att["attestation_name"]) + if id, ok := att["attestation_id"].(string); ok && id != "" && trailURL != "" { + name = fmt.Sprintf("[%s](%s?attestation_id=%s)", name, trailURL, id) + } + status, _ := att["status"].(string) + unexpected, _ := att["unexpected"].(bool) + fmt.Fprintf(b, "| %s | %s |\n", name, mdAttestationCompliance(status, att["is_compliant"], unexpected)) + } +} + +// mdAttestationCompliance maps an attestation's compliance to a glanceable emoji +// label, covering every status the server produces: MISSING (not yet reported), +// COMPLETE with is_compliant true/false, and the unexpected flag (reported but +// not expected by the template). +func mdAttestationCompliance(status string, isCompliant interface{}, unexpected bool) string { + var label string + switch status { + case "MISSING": + label = "⏳ missing" + default: + if compliant, ok := isCompliant.(bool); ok { + if compliant { + label = "✅ compliant" + } else { + label = "❌ non-compliant" + } + } else { + label = "⏳ pending" + } + } + if unexpected { + label += " (+)" + } + return label +} + +// mdEventDescription renders an event description as a markdown cell, linking +// the parts of the description that have a page in the Kosli app: +// - the environment name of started/stopped running events links to the +// environment snapshot ({host}/{org}/environments/{env}/{snapshot-index}), +// or to the environment page when no snapshot index is available +// - the attestation reference of attestation events links to the attestation +// on the trail page ({trail-url}?attestation_id={id}) +func mdEventDescription(e trailEventFields, trailURL string) string { + description := mdCell(e.description) + + if e.environmentName != "" { + envURL, err := url.JoinPath(global.Host, global.Org, "environments", e.environmentName, e.snapshotIndex) + if err == nil { + if e.snapshotIndex == "" { + envURL += "/" + } + quoted := "'" + mdCell(e.environmentName) + "'" + description = strings.Replace(description, quoted, fmt.Sprintf("[%s](%s)", quoted, envURL), 1) + } + } + + if e.attestationID != "" && e.attestationRef != "" && trailURL != "" { + ref := mdCell(e.attestationRef) + // anchor on "for " to avoid linking an attestation type that happens + // to share its name with the reference + anchored := "for " + ref + link := fmt.Sprintf("for [%s](%s?attestation_id=%s)", ref, trailURL, e.attestationID) + description = strings.Replace(description, anchored, link, 1) + } + + return description +} + +// mdEventCompliance prefixes an event compliance value with a glanceable emoji. +func mdEventCompliance(compliance string) string { + switch compliance { + case "compliant": + return "✅ " + compliance + case "non-compliant": + return "❌ " + compliance + default: + return mdCell(compliance) + } +} + func printTrailAsTable(raw string, out io.Writer, page int) error { var trail map[string]interface{} err := json.Unmarshal([]byte(raw), &trail) @@ -126,10 +398,32 @@ func printTrailAsTable(raw string, out io.Writer, page int) error { } func eventRow(event interface{}) (string, error) { + e, err := eventFields(event) + if err != nil { + return "", err + } + return fmt.Sprintf("\t%s\t%s\t%s\t%s", e.timestamp, e.description, e.commitSHA, e.compliance), nil +} + +// trailEventFields holds the displayable fields of a trail event so they can be +// rendered in any output format (table, markdown). +type trailEventFields struct { + timestamp string + description string + commitSHA string + commitURL string + compliance string + environmentName string + snapshotIndex string + attestationID string + attestationRef string // the attestation reference as it appears in the description, e.g. "artifact.snyk-scan" +} + +func eventFields(event interface{}) (trailEventFields, error) { eventMap := event.(map[string]interface{}) eventTimestamp, err := formattedTimestamp(eventMap["timestamp"].(float64), true) if err != nil { - return "", err + return trailEventFields{}, err } eventDescription := "" @@ -143,12 +437,21 @@ func eventRow(event interface{}) (string, error) { } eventCommit := "" + eventCommitURL := "" if commitInfo, ok := eventMap["git_commit_info"].(map[string]interface{}); ok { if sha1, ok := commitInfo["sha1"].(string); ok { eventCommit = sha1[0:7] } + if commitURL, ok := commitInfo["url"].(string); ok { + eventCommitURL = commitURL + } } + eventEnvironment := "" + eventSnapshotIndex := "" + eventAttestationID := "" + eventAttestationRef := "" + eventType := eventMap["type"].(string) switch eventType { case "trail_reported": @@ -157,22 +460,48 @@ func eventRow(event interface{}) (string, error) { eventDescription = "trail updated" case "trail_attestation_reported": eventDescription = fmt.Sprintf("'%s' attestation reported for %s on the trail", eventMap["attestation_type"], eventMap["template_reference_name"]) + eventAttestationRef = fmt.Sprintf("%v", eventMap["template_reference_name"]) + if id, ok := eventMap["attestation_id"].(string); ok { + eventAttestationID = id + } case "artifact_creation_reported": eventDescription = fmt.Sprintf("artifact '%s' created for template name '%s'", eventMap["artifact_name"], eventMap["template_reference_name"]) case "artifact_attestation_reported", "trail_attestation_for_artifact_reported": eventDescription = fmt.Sprintf("'%s' attestation reported for %s.%s", eventMap["attestation_type"], eventMap["target_artifact"], eventMap["template_reference_name"]) + eventAttestationRef = fmt.Sprintf("%v.%v", eventMap["target_artifact"], eventMap["template_reference_name"]) + if id, ok := eventMap["attestation_id"].(string); ok { + eventAttestationID = id + } case "artifact_approval_reported": if eventMap["state"].(string) != "PENDING" { eventDescription = fmt.Sprintf("approval #%.0f created by '%s'", eventMap["approval_number"].(float64), eventMap["reviewer"]) } else { eventDescription = fmt.Sprintf("approval #%.0f requested", eventMap["approval_number"].(float64)) } - case "artifact_started_running": - eventDescription = fmt.Sprintf("artifact '%s' started running in '%s'", eventMap["template_reference_name"], eventMap["environment_name"]) - case "artifact_stopped_running": - eventDescription = fmt.Sprintf("artifact '%s' stopped running in '%s'", eventMap["template_reference_name"], eventMap["environment_name"]) + case "artifact_started_running", "artifact_stopped_running": + verb := "started" + if eventType == "artifact_stopped_running" { + verb = "stopped" + } + eventDescription = fmt.Sprintf("artifact '%s' %s running in '%s'", eventMap["template_reference_name"], verb, eventMap["environment_name"]) + if envName, ok := eventMap["environment_name"].(string); ok { + eventEnvironment = envName + } + if snapshotIndex, ok := eventMap["snapshot_index"].(float64); ok { + eventSnapshotIndex = fmt.Sprintf("%.0f", snapshotIndex) + } default: eventDescription = eventType } - return fmt.Sprintf("\t%s\t%s\t%s\t%s", eventTimestamp, eventDescription, eventCommit, eventCompliance), nil + return trailEventFields{ + timestamp: eventTimestamp, + description: eventDescription, + commitSHA: eventCommit, + commitURL: eventCommitURL, + compliance: eventCompliance, + environmentName: eventEnvironment, + snapshotIndex: eventSnapshotIndex, + attestationID: eventAttestationID, + attestationRef: eventAttestationRef, + }, nil } diff --git a/cmd/kosli/getTrail_markdown_test.go b/cmd/kosli/getTrail_markdown_test.go new file mode 100644 index 000000000..76f734f8f --- /dev/null +++ b/cmd/kosli/getTrail_markdown_test.go @@ -0,0 +1,332 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPrintTrailAsMarkdown is a server-free unit test for the markdown renderer. +// It feeds a representative trail payload through printTrailAsMarkdown and +// compares the result against the golden file used by the integration test, so +// global opts and the flow name mirror the integration suite's values. +func TestPrintTrailAsMarkdown(t *testing.T) { + t.Setenv("KOSLI_TESTS", "true") // makes formattedTimestamp use a fixed time + global = &GlobalOpts{ + Host: "http://localhost:8001", + Org: "docs-cmd-test-user-shared", + } + o := &getTrailOptions{flowName: "get-trail"} + + // Mirrors the get-trail integration fixture: a freshly-begun trail from a + // template with a trail attestation "bar" and an artifact "cli" with + // attestation "foo", none reported yet (all MISSING). + raw := `{ + "name": "cli-build-1", + "description": "test trail", + "compliance_state": "INCOMPLETE", + "last_modified_at": 1452902400, + "compliance_status": { + "status": "INCOMPLETE", + "is_compliant": false, + "attestations_statuses": [ + {"attestation_name": "bar", "attestation_type": "generic", "attestation_id": null, "overridden_attestation_id": null, "status": "MISSING", "is_compliant": null, "unexpected": false} + ], + "artifacts_statuses": { + "cli": { + "artifact_fingerprint": null, + "artifact_id": null, + "status": "MISSING", + "is_compliant": null, + "attestations_statuses": [ + {"attestation_name": "foo", "attestation_type": "generic", "attestation_id": null, "overridden_attestation_id": null, "status": "MISSING", "is_compliant": null, "unexpected": false} + ], + "unexpected": false + } + } + }, + "events": [ + {"type": "trail_reported", "timestamp": 1452902400} + ] + }` + + var buf bytes.Buffer + err := o.printTrailAsMarkdown(raw, &buf, 0) + require.NoError(t, err) + + want, err := os.ReadFile(goldenPath("output/get/get-trail-markdown.txt")) + require.NoError(t, err) + + require.Equal(t, string(want), buf.String()) +} + +// TestPrintTrailAsMarkdownGitCommitAndEscaping covers the git-commit block and +// mdCell escaping: pipes, CR/CRLF, and angle brackets (e.g. author emails) must +// not break the table layout or be swallowed as HTML, and missing (null) fields +// must render as empty cells, not "". +func TestPrintTrailAsMarkdownGitCommitAndEscaping(t *testing.T) { + t.Setenv("KOSLI_TESTS", "true") + global = &GlobalOpts{ + Host: "https://app.kosli.com", + Org: "my-org", + } + o := &getTrailOptions{flowName: "my-flow"} + + raw := `{ + "name": "cli-build-2", + "description": null, + "compliance_state": "COMPLIANT", + "last_modified_at": 1452902400, + "origin_url": "https://github.com/kosli-dev/cli/actions/runs/123", + "git_commit_info": { + "sha1": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", + "author": "Jane | Doe ", + "timestamp": 1452902400, + "url": "https://github.com/kosli-dev/cli/commit/1a2b3c4", + "message": "fix: a | in a title\r\nsecond line\rthird line\nfourth line" + }, + "events": [] + }` + + var buf bytes.Buffer + err := o.printTrailAsMarkdown(raw, &buf, 0) + require.NoError(t, err) + + got := buf.String() + + require.Contains(t, got, "## Trail: [cli-build-2](https://app.kosli.com/my-org/flows/my-flow/trails/cli-build-2)") + require.NotContains(t, got, "", "null fields must render as empty cells") + require.NotContains(t, got, "\r", "carriage returns must not survive into the markdown") + require.Contains(t, got, "| Description | |") + require.Contains(t, got, "| Compliance | ✅ COMPLIANT |") + require.Contains(t, got, "| Origin | https://github.com/kosli-dev/cli/actions/runs/123 |") + require.Contains(t, got, "| Author | Jane \\| Doe <jane@kosli.com> |") + require.Contains(t, got, "| Sha1 | [1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b](https://github.com/kosli-dev/cli/commit/1a2b3c4) |") + require.Contains(t, got, "| Message | fix: a \\| in a title |", + "only the first line of the commit message is shown") + require.Contains(t, got, "### Git commit") + require.Contains(t, got, "_No events._") +} + +// TestPrintTrailAsMarkdownEventLinksAndCompliance covers event rows: the commit +// column links to the commit URL when available, and compliance gets a +// glanceable emoji prefix. +func TestPrintTrailAsMarkdownEventLinksAndCompliance(t *testing.T) { + t.Setenv("KOSLI_TESTS", "true") + global = &GlobalOpts{ + Host: "https://app.kosli.com", + Org: "my-org", + } + o := &getTrailOptions{flowName: "my-flow"} + + raw := `{ + "name": "cli-build-3", + "description": "events trail", + "compliance_state": "NON_COMPLIANT", + "last_modified_at": 1452902400, + "events": [ + { + "type": "trail_attestation_reported", + "timestamp": 1452902400, + "attestation_type": "junit", + "template_reference_name": "unit-tests", + "is_compliant": true, + "git_commit_info": { + "sha1": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", + "url": "https://github.com/kosli-dev/cli/commit/1a2b3c4d" + } + }, + { + "type": "trail_attestation_reported", + "timestamp": 1452902400, + "attestation_type": "snyk", + "template_reference_name": "snyk-scan", + "is_compliant": false, + "git_commit_info": { + "sha1": "9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" + } + } + ] + }` + + var buf bytes.Buffer + err := o.printTrailAsMarkdown(raw, &buf, 0) + require.NoError(t, err) + + got := buf.String() + + require.Contains(t, got, "| Compliance | ❌ NON_COMPLIANT |") + require.Contains(t, got, "| [1a2b3c4](https://github.com/kosli-dev/cli/commit/1a2b3c4d) |", + "commit column links to the commit URL when available") + require.Contains(t, got, "| 9f8e7d6 |", "commit without a URL renders as plain short sha") + require.Contains(t, got, "| ✅ compliant |") + require.Contains(t, got, "| ❌ non-compliant |") +} + +// TestPrintTrailAsMarkdownAttestationStatuses covers the attestation-statuses +// table: each attestation name links to the attestation in the app, the +// compliance column uses emoji, and every compliance status is handled +// (COMPLETE+compliant, COMPLETE+non-compliant, MISSING, and the unexpected flag), +// grouped by trail and by artifact. +func TestPrintTrailAsMarkdownAttestationStatuses(t *testing.T) { + t.Setenv("KOSLI_TESTS", "true") + global = &GlobalOpts{ + Host: "https://app.kosli.com", + Org: "my-org", + } + o := &getTrailOptions{flowName: "my-flow"} + + raw := `{ + "name": "cli-build-6", + "description": "attestation statuses trail", + "compliance_state": "NON_COMPLIANT", + "last_modified_at": 1452902400, + "compliance_status": { + "status": "NON-COMPLIANT", + "is_compliant": false, + "attestations_statuses": [ + {"attestation_name": "bar", "attestation_type": "generic", "attestation_id": "aaa-bar", "status": "COMPLETE", "is_compliant": true, "unexpected": false} + ], + "artifacts_statuses": { + "cli": { + "status": "NON-COMPLIANT", + "is_compliant": false, + "attestations_statuses": [ + {"attestation_name": "foo", "attestation_type": "generic", "attestation_id": "aaa-foo", "status": "COMPLETE", "is_compliant": true, "unexpected": false}, + {"attestation_name": "baz", "attestation_type": "snyk", "attestation_id": "aaa-baz", "status": "COMPLETE", "is_compliant": false, "unexpected": false}, + {"attestation_name": "qux", "attestation_type": "junit", "attestation_id": null, "status": "MISSING", "is_compliant": null, "unexpected": false}, + {"attestation_name": "extra", "attestation_type": "generic", "attestation_id": "aaa-extra", "status": "COMPLETE", "is_compliant": true, "unexpected": true} + ] + } + } + }, + "events": [] + }` + + var buf bytes.Buffer + err := o.printTrailAsMarkdown(raw, &buf, 0) + require.NoError(t, err) + + got := buf.String() + trailURL := "https://app.kosli.com/my-org/flows/my-flow/trails/cli-build-6" + + require.Contains(t, got, "### Attestations") + require.Contains(t, got, "**Trail**") + require.Contains(t, got, "**cli** — ❌ NON-COMPLIANT") + // trail-level attestation, linked, compliant + require.Contains(t, got, fmt.Sprintf("| [bar](%s?attestation_id=aaa-bar) | ✅ compliant |", trailURL)) + // artifact attestations: compliant, non-compliant, missing (unlinked), unexpected + require.Contains(t, got, fmt.Sprintf("| [foo](%s?attestation_id=aaa-foo) | ✅ compliant |", trailURL)) + require.Contains(t, got, fmt.Sprintf("| [baz](%s?attestation_id=aaa-baz) | ❌ non-compliant |", trailURL)) + require.Contains(t, got, "| qux | ⏳ missing |") + require.Contains(t, got, fmt.Sprintf("| [extra](%s?attestation_id=aaa-extra) | ✅ compliant (+) |", trailURL)) +} + +// TestPrintTrailAsMarkdownAttestationLinks covers linking attestation events to +// the attestation in the Kosli app: +// {host}/{org}/flows/{flow}/trails/{trail}?attestation_id={id}. +func TestPrintTrailAsMarkdownAttestationLinks(t *testing.T) { + t.Setenv("KOSLI_TESTS", "true") + global = &GlobalOpts{ + Host: "https://app.kosli.com", + Org: "my-org", + } + o := &getTrailOptions{flowName: "my-flow"} + + raw := `{ + "name": "cli-build-5", + "description": "attestation trail", + "compliance_state": "COMPLIANT", + "last_modified_at": 1452902400, + "events": [ + { + "type": "trail_attestation_for_artifact_reported", + "timestamp": 1452902400, + "attestation_type": "snyk", + "target_artifact": "artifact", + "template_reference_name": "snyk-code-test", + "attestation_id": "b8366cb0-249f-419e-b68a-d7046b7b", + "is_compliant": true + }, + { + "type": "trail_attestation_reported", + "timestamp": 1452902400, + "attestation_type": "junit", + "template_reference_name": "unit-tests", + "attestation_id": "59d541bb-136c-49d7-a086-6c4dc5eb", + "is_compliant": true + }, + { + "type": "trail_attestation_reported", + "timestamp": 1452902400, + "attestation_type": "generic", + "template_reference_name": "no-id-attestation", + "is_compliant": true + } + ] + }` + + var buf bytes.Buffer + err := o.printTrailAsMarkdown(raw, &buf, 0) + require.NoError(t, err) + + got := buf.String() + + require.Contains(t, got, + "'snyk' attestation reported for [artifact.snyk-code-test](https://app.kosli.com/my-org/flows/my-flow/trails/cli-build-5?attestation_id=b8366cb0-249f-419e-b68a-d7046b7b)") + require.Contains(t, got, + "'junit' attestation reported for [unit-tests](https://app.kosli.com/my-org/flows/my-flow/trails/cli-build-5?attestation_id=59d541bb-136c-49d7-a086-6c4dc5eb) on the trail") + require.Contains(t, got, + "'generic' attestation reported for no-id-attestation on the trail", + "an attestation event without an id stays unlinked") +} + +// TestPrintTrailAsMarkdownEnvironmentLinks covers linking the environment name +// in started/stopped running events to the environment snapshot in the Kosli +// app: {host}/{org}/environments/{env}/{snapshot-index}, falling back to the +// environment page when no snapshot index is present. +func TestPrintTrailAsMarkdownEnvironmentLinks(t *testing.T) { + t.Setenv("KOSLI_TESTS", "true") + global = &GlobalOpts{ + Host: "https://app.kosli.com", + Org: "my-org", + } + o := &getTrailOptions{flowName: "my-flow"} + + raw := `{ + "name": "cli-build-4", + "description": "env trail", + "compliance_state": "COMPLIANT", + "last_modified_at": 1452902400, + "events": [ + { + "type": "artifact_started_running", + "timestamp": 1452902400, + "template_reference_name": "artifact", + "environment_name": "staging-aws", + "snapshot_index": 15144 + }, + { + "type": "artifact_stopped_running", + "timestamp": 1452902400, + "template_reference_name": "artifact", + "environment_name": "prod-aws" + } + ] + }` + + var buf bytes.Buffer + err := o.printTrailAsMarkdown(raw, &buf, 0) + require.NoError(t, err) + + got := buf.String() + + require.Contains(t, got, + "artifact 'artifact' started running in ['staging-aws'](https://app.kosli.com/my-org/environments/staging-aws/15144)") + require.Contains(t, got, + "artifact 'artifact' stopped running in ['prod-aws'](https://app.kosli.com/my-org/environments/prod-aws/)", + "without a snapshot index the link falls back to the environment page") +} diff --git a/cmd/kosli/getTrail_test.go b/cmd/kosli/getTrail_test.go index 7500b5e7e..1c9f2605b 100644 --- a/cmd/kosli/getTrail_test.go +++ b/cmd/kosli/getTrail_test.go @@ -60,6 +60,11 @@ func (suite *GetTrailCommandTestSuite) TestGetTrailCmd() { name: "getting an existing trail with --output json works", cmd: fmt.Sprintf(`get trail %s --flow %s --output json %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), }, + { + name: "getting an existing trail with --output markdown works", + cmd: fmt.Sprintf(`get trail %s --flow %s --output markdown %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), + goldenFileExact: "output/get/get-trail-markdown.txt", + }, } runTestCmd(suite.T(), tests) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index bebb8c6c0..5db830f6e 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -107,6 +107,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, templateArtifactName = "The name of the artifact in the yml template file." flowNamesFlag = "[defaulted] The comma separated list of Kosli flows. Defaults to all flows of the org." outputFlag = "[defaulted] The format of the output. Valid formats are: [table, json]." + outputFlagWithMarkdown = "[defaulted] The format of the output. Valid formats are: [table, json, markdown]." searchByNameFlag = "[optional] Only list flows whose name contains this substring. The Kosli API supports alphanumeric characters and '-'." ignoreCaseFlag = "[optional] Perform case-insensitive matching for --name. By default matching is case sensitive." serviceAccountNameFlag = "The name of the service account whose API keys are managed." diff --git a/cmd/kosli/testHelpers.go b/cmd/kosli/testHelpers.go index c2178423a..52a23a042 100644 --- a/cmd/kosli/testHelpers.go +++ b/cmd/kosli/testHelpers.go @@ -40,7 +40,8 @@ type cmdTestCase struct { name string cmd string golden string - goldenFile string + goldenFile string // file of per-line regex patterns (use for output with varying parts, e.g. timestamps) + goldenFileExact string // file compared exactly (use for deterministic output containing regex metacharacters, e.g. markdown) goldenRegex string goldenJson []jsonCheck // Use like this for array {"[0].compliant", false} goldenStdout string // expected stdout only (exact match, ignored when empty) @@ -108,6 +109,13 @@ func runTestCmd(t *testing.T, tests []cmdTestCase) { if err := compareAgainstFile([]byte(combined), goldenPath(tt.goldenFile)); err != nil { t.Error(err) } + } else if tt.goldenFileExact != "" { + expected, err := os.ReadFile(goldenPath(tt.goldenFileExact)) + if err != nil { + t.Error(err) + } else if err := compareFileBytes([]byte(combined), expected); err != nil { + t.Errorf("does not match golden file %s\n\nWANT:\n'%s'\n\nGOT:\n'%s'\n", tt.goldenFileExact, expected, combined) + } } else if tt.goldenRegex != "" { require.Regexp(t, tt.goldenRegex, combined) } else if len(tt.goldenJson) > 0 { diff --git a/cmd/kosli/testdata/output/get/get-trail-markdown.txt b/cmd/kosli/testdata/output/get/get-trail-markdown.txt new file mode 100644 index 000000000..958c83398 --- /dev/null +++ b/cmd/kosli/testdata/output/get/get-trail-markdown.txt @@ -0,0 +1,28 @@ +## Trail: [cli-build-1](http://localhost:8001/docs-cmd-test-user-shared/flows/get-trail/trails/cli-build-1) + +| | | +| --- | --- | +| Name | cli-build-1 | +| Description | test trail | +| Compliance | ⏳ INCOMPLETE | +| Last modified at | Sat, 16 Jan 2016 00:00:00 UTC • 2016-01-16 | + +### Attestations + +**Trail** + +| | | +| --- | --- | +| bar | ⏳ missing | + +**cli** — ⏳ MISSING + +| | | +| --- | --- | +| foo | ⏳ missing | + +### Events + +| Time | Description | Git commit | Compliance | +| --- | --- | --- | --- | +| Sat, 16 Jan 2016 00:00:00 UTC | trail started | | |