From 7310d072fefef1ae6193166c86056da8846c9d7a Mon Sep 17 00:00:00 2001 From: Mike Long Date: Thu, 11 Jun 2026 11:19:28 +0200 Subject: [PATCH 01/10] feat(get trail): add --output markdown format Render `kosli get trail` as GitHub-Flavored Markdown so the result can be piped into a CI job summary (GitHub's $GITHUB_STEP_SUMMARY) or a GitLab summary.md artifact. This is the first slice of an explicit, opt-in alternative to an implicit per-command CI summary (see #904): output formatting is already a format->renderer registry, so `markdown` slots in next to `table`/`json`. - add printTrailAsMarkdown + mdCell cell-escaping helper - extract eventFields from eventRow so table and markdown share field logic (table output is byte-identical) - register markdown in the get trail output map and update its --output help - golden test for `get trail --output markdown` + a server-free renderer unit test Refs #904 Signed-off-by: Mike Long --- cmd/kosli/getTrail.go | 92 ++++++++++++++++++- cmd/kosli/getTrail_markdown_test.go | 35 +++++++ cmd/kosli/getTrail_test.go | 5 + cmd/kosli/root.go | 1 + .../output/get/get-trail-markdown.txt | 14 +++ 5 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 cmd/kosli/getTrail_markdown_test.go create mode 100644 cmd/kosli/testdata/output/get/get-trail-markdown.txt diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index 37f786271..560ef7e98 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "strings" "github.com/kosli-dev/cli/internal/output" "github.com/kosli-dev/cli/internal/requests" @@ -39,7 +40,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 +68,82 @@ 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": 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). +func 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 + fmt.Fprintf(&b, "## Trail: %s\n\n", mdCell(trail["name"])) + b.WriteString("| Field | Value |\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", mdCell(trail["compliance_state"])) + fmt.Fprintf(&b, "| Last modified at | %s |\n", mdCell(lastModifiedAt)) + + if commitInfo, ok := trail["git_commit_info"].(map[string]interface{}); ok { + commitTimestamp, err := formattedTimestamp(commitInfo["timestamp"], false) + if err != nil { + return err + } + b.WriteString("\n### Git commit\n\n") + b.WriteString("| Field | Value |\n") + b.WriteString("| --- | --- |\n") + fmt.Fprintf(&b, "| Sha1 | %s |\n", mdCell(commitInfo["sha1"])) + fmt.Fprintf(&b, "| Author | %s |\n", mdCell(commitInfo["author"])) + fmt.Fprintf(&b, "| Timestamp | %s |\n", mdCell(commitTimestamp)) + if url, ok := commitInfo["url"]; ok { + fmt.Fprintf(&b, "| URL | %s |\n", mdCell(url)) + } + fmt.Fprintf(&b, "| Message | %s |\n", mdCell(commitInfo["message"])) + } + + 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 { + timestamp, description, commit, compliance, err := eventFields(event) + if err != nil { + return err + } + fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", + mdCell(timestamp), mdCell(description), mdCell(commit), mdCell(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. +func mdCell(v interface{}) string { + s := fmt.Sprintf("%v", v) + s = strings.ReplaceAll(s, "|", "\\|") + s = strings.ReplaceAll(s, "\n", "
") + return s +} + func printTrailAsTable(raw string, out io.Writer, page int) error { var trail map[string]interface{} err := json.Unmarshal([]byte(raw), &trail) @@ -126,10 +198,20 @@ func printTrailAsTable(raw string, out io.Writer, page int) error { } func eventRow(event interface{}) (string, error) { + eventTimestamp, eventDescription, eventCommit, eventCompliance, err := eventFields(event) + if err != nil { + return "", err + } + return fmt.Sprintf("\t%s\t%s\t%s\t%s", eventTimestamp, eventDescription, eventCommit, eventCompliance), nil +} + +// eventFields extracts the displayable fields of a trail event so they can be +// rendered in any output format (table, markdown). +func eventFields(event interface{}) (timestamp, description, commit, compliance string, err error) { eventMap := event.(map[string]interface{}) eventTimestamp, err := formattedTimestamp(eventMap["timestamp"].(float64), true) if err != nil { - return "", err + return "", "", "", "", err } eventDescription := "" @@ -174,5 +256,5 @@ func eventRow(event interface{}) (string, error) { default: eventDescription = eventType } - return fmt.Sprintf("\t%s\t%s\t%s\t%s", eventTimestamp, eventDescription, eventCommit, eventCompliance), nil + return eventTimestamp, eventDescription, eventCommit, eventCompliance, nil } diff --git a/cmd/kosli/getTrail_markdown_test.go b/cmd/kosli/getTrail_markdown_test.go new file mode 100644 index 000000000..e8b97e1b5 --- /dev/null +++ b/cmd/kosli/getTrail_markdown_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "bytes" + "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. +func TestPrintTrailAsMarkdown(t *testing.T) { + t.Setenv("KOSLI_TESTS", "true") // makes formattedTimestamp use a fixed time + + raw := `{ + "name": "cli-build-1", + "description": "test trail", + "compliance_state": "INCOMPLETE", + "last_modified_at": 1452902400, + "events": [ + {"type": "trail_reported", "timestamp": 1452902400} + ] + }` + + var buf bytes.Buffer + err := 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()) +} diff --git a/cmd/kosli/getTrail_test.go b/cmd/kosli/getTrail_test.go index 7500b5e7e..78ade5614 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), + goldenFile: "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/testdata/output/get/get-trail-markdown.txt b/cmd/kosli/testdata/output/get/get-trail-markdown.txt new file mode 100644 index 000000000..379ecb968 --- /dev/null +++ b/cmd/kosli/testdata/output/get/get-trail-markdown.txt @@ -0,0 +1,14 @@ +## Trail: cli-build-1 + +| Field | Value | +| --- | --- | +| Name | cli-build-1 | +| Description | test trail | +| Compliance | INCOMPLETE | +| Last modified at | Sat, 16 Jan 2016 00:00:00 UTC • 2016-01-16 | + +### Events + +| Time | Description | Git commit | Compliance | +| --- | --- | --- | --- | +| Sat, 16 Jan 2016 00:00:00 UTC | trail started | | | From 415038520645d9adca2ec07d744fd339a779d17d Mon Sep 17 00:00:00 2001 From: Mike Long Date: Fri, 12 Jun 2026 11:46:14 +0200 Subject: [PATCH 02/10] fix(get trail): harden markdown cell escaping and nil rendering Review findings on the markdown renderer: - mdCell escaped LF but not CR/CRLF; per CommonMark a bare CR is a line ending, so a CRLF git commit message terminated a table row mid-cell. Normalize CRLF and CR to LF before the
replacement. - mdCell(nil) rendered the literal "" for missing fields (e.g. a trail with no description); render an empty cell instead. - Add a unit case covering the git-commit block, the empty-events path, and pipe/LF/CRLF/CR escaping - none of which the original fixture exercised. Refs #904 Signed-off-by: Mike Long --- cmd/kosli/getTrail.go | 8 ++++++- cmd/kosli/getTrail_markdown_test.go | 37 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index 560ef7e98..a4f8a61e9 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -136,10 +136,16 @@ func printTrailAsMarkdown(raw string, out io.Writer, page int) error { } // mdCell renders a value as a single markdown table cell, escaping characters -// that would otherwise break the table layout. +// that would otherwise break the table layout. 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, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") s = strings.ReplaceAll(s, "\n", "
") return s } diff --git a/cmd/kosli/getTrail_markdown_test.go b/cmd/kosli/getTrail_markdown_test.go index e8b97e1b5..3e266a149 100644 --- a/cmd/kosli/getTrail_markdown_test.go +++ b/cmd/kosli/getTrail_markdown_test.go @@ -33,3 +33,40 @@ func TestPrintTrailAsMarkdown(t *testing.T) { require.Equal(t, string(want), buf.String()) } + +// TestPrintTrailAsMarkdownGitCommitAndEscaping covers the git-commit block and +// mdCell escaping: pipes, LF, CRLF and bare CR must not break the table layout, +// and missing (null) fields must render as empty cells, not "". +func TestPrintTrailAsMarkdownGitCommitAndEscaping(t *testing.T) { + t.Setenv("KOSLI_TESTS", "true") + + raw := `{ + "name": "cli-build-2", + "description": null, + "compliance_state": "COMPLIANT", + "last_modified_at": 1452902400, + "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 := printTrailAsMarkdown(raw, &buf, 0) + require.NoError(t, err) + + got := buf.String() + + 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, "| Author | Jane \\| Doe |") + require.Contains(t, got, "| Message | fix: a \\| in a title
second line
third line
fourth line |") + require.Contains(t, got, "### Git commit") + require.Contains(t, got, "| URL | https://github.com/kosli-dev/cli/commit/1a2b3c4 |") + require.Contains(t, got, "_No events._") +} From 54c25b9a53732e8d26fa504ebdadb01f69788205 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Fri, 12 Jun 2026 12:17:14 +0200 Subject: [PATCH 03/10] feat(get trail): links, emoji and tighter commit message in markdown output Improvements driven by rendering a real production trail: - Trail heading links to the trail page in the Kosli app (host/org/flows/flow/trails/name), so the CI summary links back to Kosli. printTrailAsMarkdown becomes a method on getTrailOptions to access the flow name. - Git commit sha links to the commit URL, both in the Git commit block (replacing the separate URL row) and in the events table. eventFields now returns a trailEventFields struct carrying the commit URL, which also removes the unused named returns. - Compliance values get a glanceable emoji prefix: COMPLIANT, NON_COMPLIANT, INCOMPLETE and per-event compliant/non-compliant. - Only the first line of the commit message is shown; a full PR-description-sized message flattened with
dominated the summary. - mdCell also escapes &, < and > so commit authors like "Name " are not swallowed as HTML by GFM renderers. - New Origin row links the summary to the CI run that produced the trail, when origin_url is set. Refs #904 Signed-off-by: Mike Long --- cmd/kosli/getTrail.go | 115 +++++++++++++++--- cmd/kosli/getTrail_markdown_test.go | 90 ++++++++++++-- .../output/get/get-trail-markdown.txt | 4 +- 3 files changed, 178 insertions(+), 31 deletions(-) diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index a4f8a61e9..8b1a5f058 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -70,14 +70,15 @@ func (o *getTrailOptions) run(out io.Writer, args []string) error { map[string]output.FormatOutputFunc{ "table": printTrailAsTable, "json": output.PrintJson, - "markdown": printTrailAsMarkdown, + "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). -func printTrailAsMarkdown(raw string, out io.Writer, page int) error { +// 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 { @@ -90,29 +91,37 @@ func printTrailAsMarkdown(raw string, out io.Writer, page int) error { } var b strings.Builder - fmt.Fprintf(&b, "## Trail: %s\n\n", mdCell(trail["name"])) + heading := mdCell(trail["name"]) + if trailURL, err := url.JoinPath(global.Host, global.Org, "flows", o.flowName, "trails", fmt.Sprintf("%v", trail["name"])); err == nil { + heading = fmt.Sprintf("[%s](%s)", heading, trailURL) + } + fmt.Fprintf(&b, "## Trail: %s\n\n", heading) b.WriteString("| Field | Value |\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", mdCell(trail["compliance_state"])) + 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("| Field | Value |\n") b.WriteString("| --- | --- |\n") - fmt.Fprintf(&b, "| Sha1 | %s |\n", mdCell(commitInfo["sha1"])) + fmt.Fprintf(&b, "| Sha1 | %s |\n", sha) fmt.Fprintf(&b, "| Author | %s |\n", mdCell(commitInfo["author"])) fmt.Fprintf(&b, "| Timestamp | %s |\n", mdCell(commitTimestamp)) - if url, ok := commitInfo["url"]; ok { - fmt.Fprintf(&b, "| URL | %s |\n", mdCell(url)) - } - fmt.Fprintf(&b, "| Message | %s |\n", mdCell(commitInfo["message"])) + fmt.Fprintf(&b, "| Message | %s |\n", mdCell(firstLine(commitInfo["message"]))) } b.WriteString("\n### Events\n\n") @@ -120,12 +129,16 @@ func printTrailAsMarkdown(raw string, out io.Writer, page int) error { b.WriteString("| Time | Description | Git commit | Compliance |\n") b.WriteString("| --- | --- | --- | --- |\n") for _, event := range events { - timestamp, description, commit, compliance, err := eventFields(event) + 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(timestamp), mdCell(description), mdCell(commit), mdCell(compliance)) + mdCell(e.timestamp), mdCell(e.description), commit, mdEventCompliance(e.compliance)) } } else { b.WriteString("_No events._\n") @@ -136,13 +149,17 @@ func printTrailAsMarkdown(raw string, out io.Writer, page int) error { } // mdCell renders a value as a single markdown table cell, escaping characters -// that would otherwise break the table layout. CR and CRLF count as line -// endings in CommonMark, so they must be normalized along with LF. +// 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") @@ -150,6 +167,46 @@ func mdCell(v interface{}) string { 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 compliance state with a glanceable emoji. +func mdComplianceState(v interface{}) string { + s := mdCell(v) + switch s { + case "COMPLIANT": + return "✅ " + s + case "NON_COMPLIANT", "NON-COMPLIANT": + return "❌ " + s + case "INCOMPLETE": + return "⏳ " + s + default: + return s + } +} + +// 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) @@ -204,20 +261,28 @@ func printTrailAsTable(raw string, out io.Writer, page int) error { } func eventRow(event interface{}) (string, error) { - eventTimestamp, eventDescription, eventCommit, eventCompliance, err := eventFields(event) + e, err := eventFields(event) if err != nil { return "", err } - return fmt.Sprintf("\t%s\t%s\t%s\t%s", eventTimestamp, eventDescription, eventCommit, eventCompliance), nil + return fmt.Sprintf("\t%s\t%s\t%s\t%s", e.timestamp, e.description, e.commitSHA, e.compliance), nil } -// eventFields extracts the displayable fields of a trail event so they can be +// trailEventFields holds the displayable fields of a trail event so they can be // rendered in any output format (table, markdown). -func eventFields(event interface{}) (timestamp, description, commit, compliance string, err error) { +type trailEventFields struct { + timestamp string + description string + commitSHA string + commitURL string + compliance string +} + +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 := "" @@ -231,10 +296,14 @@ func eventFields(event interface{}) (timestamp, description, commit, compliance } 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 + } } eventType := eventMap["type"].(string) @@ -262,5 +331,11 @@ func eventFields(event interface{}) (timestamp, description, commit, compliance default: eventDescription = eventType } - return eventTimestamp, eventDescription, eventCommit, eventCompliance, nil + return trailEventFields{ + timestamp: eventTimestamp, + description: eventDescription, + commitSHA: eventCommit, + commitURL: eventCommitURL, + compliance: eventCompliance, + }, nil } diff --git a/cmd/kosli/getTrail_markdown_test.go b/cmd/kosli/getTrail_markdown_test.go index 3e266a149..d450a08fd 100644 --- a/cmd/kosli/getTrail_markdown_test.go +++ b/cmd/kosli/getTrail_markdown_test.go @@ -10,9 +10,15 @@ import ( // 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. +// 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"} raw := `{ "name": "cli-build-1", @@ -25,7 +31,7 @@ func TestPrintTrailAsMarkdown(t *testing.T) { }` var buf bytes.Buffer - err := printTrailAsMarkdown(raw, &buf, 0) + err := o.printTrailAsMarkdown(raw, &buf, 0) require.NoError(t, err) want, err := os.ReadFile(goldenPath("output/get/get-trail-markdown.txt")) @@ -35,19 +41,26 @@ func TestPrintTrailAsMarkdown(t *testing.T) { } // TestPrintTrailAsMarkdownGitCommitAndEscaping covers the git-commit block and -// mdCell escaping: pipes, LF, CRLF and bare CR must not break the table layout, -// and missing (null) fields must render as empty cells, not "". +// 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", + "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" @@ -56,17 +69,76 @@ func TestPrintTrailAsMarkdownGitCommitAndEscaping(t *testing.T) { }` var buf bytes.Buffer - err := printTrailAsMarkdown(raw, &buf, 0) + 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, "| Author | Jane \\| Doe |") - require.Contains(t, got, "| Message | fix: a \\| in a title
second line
third line
fourth line |") + 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, "| URL | https://github.com/kosli-dev/cli/commit/1a2b3c4 |") 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 |") +} diff --git a/cmd/kosli/testdata/output/get/get-trail-markdown.txt b/cmd/kosli/testdata/output/get/get-trail-markdown.txt index 379ecb968..97f92d2f2 100644 --- a/cmd/kosli/testdata/output/get/get-trail-markdown.txt +++ b/cmd/kosli/testdata/output/get/get-trail-markdown.txt @@ -1,10 +1,10 @@ -## Trail: cli-build-1 +## Trail: [cli-build-1](http://localhost:8001/docs-cmd-test-user-shared/flows/get-trail/trails/cli-build-1) | Field | Value | | --- | --- | | Name | cli-build-1 | | Description | test trail | -| Compliance | INCOMPLETE | +| Compliance | ⏳ INCOMPLETE | | Last modified at | Sat, 16 Jan 2016 00:00:00 UTC • 2016-01-16 | ### Events From 9097cfde089c707112b9d42ad770f0e650719821 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Fri, 12 Jun 2026 12:25:01 +0200 Subject: [PATCH 04/10] feat(get trail): link environment snapshots in markdown event rows Started/stopped running events now link the environment name 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. eventFields captures environment_name and snapshot_index for the two running event types, and the merged switch case derives the verb from the event type, keeping table output identical. Approval events are intentionally left unlinked as the feature is slated for deprecation. Refs #904 Signed-off-by: Mike Long --- cmd/kosli/getTrail.go | 65 ++++++++++++++++++++++------- cmd/kosli/getTrail_markdown_test.go | 47 +++++++++++++++++++++ 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index 8b1a5f058..48837a4c5 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -138,7 +138,7 @@ func (o *getTrailOptions) printTrailAsMarkdown(raw string, out io.Writer, page i commit = fmt.Sprintf("[%s](%s)", commit, e.commitURL) } fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", - mdCell(e.timestamp), mdCell(e.description), commit, mdEventCompliance(e.compliance)) + mdCell(e.timestamp), mdEventDescription(e), commit, mdEventCompliance(e.compliance)) } } else { b.WriteString("_No events._\n") @@ -195,6 +195,26 @@ func mdComplianceState(v interface{}) string { } } +// mdEventDescription renders an event description as a markdown cell, linking +// the environment name of started/stopped running events to the environment +// snapshot in the Kosli app ({host}/{org}/environments/{env}/{snapshot-index}), +// or to the environment page when no snapshot index is available. +func mdEventDescription(e trailEventFields) string { + description := mdCell(e.description) + if e.environmentName == "" { + return description + } + envURL, err := url.JoinPath(global.Host, global.Org, "environments", e.environmentName, e.snapshotIndex) + if err != nil { + return description + } + if e.snapshotIndex == "" { + envURL += "/" + } + quoted := "'" + mdCell(e.environmentName) + "'" + return strings.Replace(description, quoted, fmt.Sprintf("[%s](%s)", quoted, envURL), 1) +} + // mdEventCompliance prefixes an event compliance value with a glanceable emoji. func mdEventCompliance(compliance string) string { switch compliance { @@ -271,11 +291,13 @@ func eventRow(event interface{}) (string, error) { // 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 + timestamp string + description string + commitSHA string + commitURL string + compliance string + environmentName string + snapshotIndex string } func eventFields(event interface{}) (trailEventFields, error) { @@ -306,6 +328,9 @@ func eventFields(event interface{}) (trailEventFields, error) { } } + eventEnvironment := "" + eventSnapshotIndex := "" + eventType := eventMap["type"].(string) switch eventType { case "trail_reported": @@ -324,18 +349,28 @@ func eventFields(event interface{}) (trailEventFields, error) { } 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 trailEventFields{ - timestamp: eventTimestamp, - description: eventDescription, - commitSHA: eventCommit, - commitURL: eventCommitURL, - compliance: eventCompliance, + timestamp: eventTimestamp, + description: eventDescription, + commitSHA: eventCommit, + commitURL: eventCommitURL, + compliance: eventCompliance, + environmentName: eventEnvironment, + snapshotIndex: eventSnapshotIndex, }, nil } diff --git a/cmd/kosli/getTrail_markdown_test.go b/cmd/kosli/getTrail_markdown_test.go index d450a08fd..61be7159d 100644 --- a/cmd/kosli/getTrail_markdown_test.go +++ b/cmd/kosli/getTrail_markdown_test.go @@ -142,3 +142,50 @@ func TestPrintTrailAsMarkdownEventLinksAndCompliance(t *testing.T) { require.Contains(t, got, "| ✅ compliant |") require.Contains(t, got, "| ❌ non-compliant |") } + +// 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") +} From 00571580567fe20f154de3d08a3d3e8177a4244f Mon Sep 17 00:00:00 2001 From: Mike Long Date: Fri, 12 Jun 2026 13:03:58 +0200 Subject: [PATCH 05/10] feat(get trail): link attestation events to the attestation in markdown output Attestation events now link their reference (e.g. artifact.snyk-scan, or the template reference name for trail-level attestations) to the attestation on the trail page: {trail-url}?attestation_id={id}. Events without an attestation_id stay unlinked. The replacement is anchored on "for " so an attestation type sharing its name with the reference cannot be linked by mistake. The trail URL is now computed once and shared by the heading and event links. Refs #904 Signed-off-by: Mike Long --- cmd/kosli/getTrail.go | 63 +++++++++++++++++++++-------- cmd/kosli/getTrail_markdown_test.go | 59 +++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 16 deletions(-) diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index 48837a4c5..07f719003 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -91,8 +91,12 @@ func (o *getTrailOptions) printTrailAsMarkdown(raw string, out io.Writer, page i } 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, err := url.JoinPath(global.Host, global.Org, "flows", o.flowName, "trails", fmt.Sprintf("%v", trail["name"])); err == nil { + if trailURL != "" { heading = fmt.Sprintf("[%s](%s)", heading, trailURL) } fmt.Fprintf(&b, "## Trail: %s\n\n", heading) @@ -138,7 +142,7 @@ func (o *getTrailOptions) printTrailAsMarkdown(raw string, out io.Writer, page i commit = fmt.Sprintf("[%s](%s)", commit, e.commitURL) } fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", - mdCell(e.timestamp), mdEventDescription(e), commit, mdEventCompliance(e.compliance)) + mdCell(e.timestamp), mdEventDescription(e, trailURL), commit, mdEventCompliance(e.compliance)) } } else { b.WriteString("_No events._\n") @@ -196,23 +200,36 @@ func mdComplianceState(v interface{}) string { } // mdEventDescription renders an event description as a markdown cell, linking -// the environment name of started/stopped running events to the environment -// snapshot in the Kosli app ({host}/{org}/environments/{env}/{snapshot-index}), -// or to the environment page when no snapshot index is available. -func mdEventDescription(e trailEventFields) string { +// 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 == "" { - return description - } - envURL, err := url.JoinPath(global.Host, global.Org, "environments", e.environmentName, e.snapshotIndex) - if err != nil { - return 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.snapshotIndex == "" { - envURL += "/" + + 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) } - quoted := "'" + mdCell(e.environmentName) + "'" - return strings.Replace(description, quoted, fmt.Sprintf("[%s](%s)", quoted, envURL), 1) + + return description } // mdEventCompliance prefixes an event compliance value with a glanceable emoji. @@ -298,6 +315,8 @@ type trailEventFields struct { 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) { @@ -330,6 +349,8 @@ func eventFields(event interface{}) (trailEventFields, error) { eventEnvironment := "" eventSnapshotIndex := "" + eventAttestationID := "" + eventAttestationRef := "" eventType := eventMap["type"].(string) switch eventType { @@ -339,10 +360,18 @@ func eventFields(event interface{}) (trailEventFields, 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"]) @@ -372,5 +401,7 @@ func eventFields(event interface{}) (trailEventFields, error) { 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 index 61be7159d..206972577 100644 --- a/cmd/kosli/getTrail_markdown_test.go +++ b/cmd/kosli/getTrail_markdown_test.go @@ -143,6 +143,65 @@ func TestPrintTrailAsMarkdownEventLinksAndCompliance(t *testing.T) { require.Contains(t, got, "| ❌ non-compliant |") } +// 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 From 857e8fea7bd56544e6142bdd36c4e5c7e2defefe Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sun, 14 Jun 2026 17:28:25 +0200 Subject: [PATCH 06/10] feat(get trail): drop "Field | Value" headers from markdown metadata tables The trail metadata and git commit tables are key/value, so the column headers add noise. GFM tables require a header row, so use an empty one. Refs #904 Signed-off-by: Mike Long --- cmd/kosli/getTrail.go | 4 ++-- cmd/kosli/testdata/output/get/get-trail-markdown.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index 07f719003..8efa70b20 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -100,7 +100,7 @@ func (o *getTrailOptions) printTrailAsMarkdown(raw string, out io.Writer, page i heading = fmt.Sprintf("[%s](%s)", heading, trailURL) } fmt.Fprintf(&b, "## Trail: %s\n\n", heading) - b.WriteString("| Field | Value |\n") + b.WriteString("| | |\n") b.WriteString("| --- | --- |\n") fmt.Fprintf(&b, "| Name | %s |\n", mdCell(trail["name"])) fmt.Fprintf(&b, "| Description | %s |\n", mdCell(trail["description"])) @@ -120,7 +120,7 @@ func (o *getTrailOptions) printTrailAsMarkdown(raw string, out io.Writer, page i sha = fmt.Sprintf("[%s](%s)", sha, commitURL) } b.WriteString("\n### Git commit\n\n") - b.WriteString("| Field | Value |\n") + b.WriteString("| | |\n") b.WriteString("| --- | --- |\n") fmt.Fprintf(&b, "| Sha1 | %s |\n", sha) fmt.Fprintf(&b, "| Author | %s |\n", mdCell(commitInfo["author"])) diff --git a/cmd/kosli/testdata/output/get/get-trail-markdown.txt b/cmd/kosli/testdata/output/get/get-trail-markdown.txt index 97f92d2f2..ec3072785 100644 --- a/cmd/kosli/testdata/output/get/get-trail-markdown.txt +++ b/cmd/kosli/testdata/output/get/get-trail-markdown.txt @@ -1,6 +1,6 @@ ## Trail: [cli-build-1](http://localhost:8001/docs-cmd-test-user-shared/flows/get-trail/trails/cli-build-1) -| Field | Value | +| | | | --- | --- | | Name | cli-build-1 | | Description | test trail | From 2ebc19f799b1fdb99d543a8ebbf7b4a5028d1c06 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sun, 14 Jun 2026 17:48:08 +0200 Subject: [PATCH 07/10] feat(get trail): add attestation statuses table to markdown output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render an "### Attestations" section: headerless two-column tables of attestation name (linked to the attestation on the trail page via ?attestation_id=) and its compliance status as an emoji, grouped by the trail and by each artifact (with the artifact's own compliance state). All server-defined statuses are handled (per server trails.py / compliance_checker.py): MISSING -> ⏳, COMPLETE+is_compliant true -> ✅, COMPLETE+is_compliant false -> ❌, and the unexpected flag (reported but not in the template) -> ⚠️. mdComplianceState also gains MISSING for artifact-level status. The section is omitted when a trail has no attestation statuses. The get-trail integration golden gains the section because its template declares a trail attestation (bar) and an artifact (cli/foo) that are MISSING on a freshly-begun trail. Refs #904 Signed-off-by: Mike Long --- cmd/kosli/getTrail.go | 104 +++++++++++++++++- cmd/kosli/getTrail_markdown_test.go | 82 ++++++++++++++ .../output/get/get-trail-markdown.txt | 14 +++ 3 files changed, 198 insertions(+), 2 deletions(-) diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index 8efa70b20..811bc5aad 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "sort" "strings" "github.com/kosli-dev/cli/internal/output" @@ -128,6 +129,8 @@ func (o *getTrailOptions) printTrailAsMarkdown(raw string, out io.Writer, page i 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") @@ -184,7 +187,9 @@ func firstLine(v interface{}) string { return s } -// mdComplianceState prefixes a trail compliance state with a glanceable emoji. +// 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 { @@ -192,13 +197,108 @@ func mdComplianceState(v interface{}) string { return "✅ " + s case "NON_COMPLIANT", "NON-COMPLIANT": return "❌ " + s - case "INCOMPLETE": + 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 { + case status == "MISSING": + label = "⏳ missing" + default: + if compliant, ok := isCompliant.(bool); ok { + if compliant { + label = "✅ compliant" + } else { + label = "❌ non-compliant" + } + } else { + label = "⏳ pending" + } + } + if unexpected { + label += " — ⚠️ unexpected" + } + 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 diff --git a/cmd/kosli/getTrail_markdown_test.go b/cmd/kosli/getTrail_markdown_test.go index 206972577..d42c82470 100644 --- a/cmd/kosli/getTrail_markdown_test.go +++ b/cmd/kosli/getTrail_markdown_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "fmt" "os" "testing" @@ -20,11 +21,33 @@ func TestPrintTrailAsMarkdown(t *testing.T) { } 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} ] @@ -143,6 +166,65 @@ func TestPrintTrailAsMarkdownEventLinksAndCompliance(t *testing.T) { 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 — ⚠️ unexpected |", trailURL)) +} + // TestPrintTrailAsMarkdownAttestationLinks covers linking attestation events to // the attestation in the Kosli app: // {host}/{org}/flows/{flow}/trails/{trail}?attestation_id={id}. diff --git a/cmd/kosli/testdata/output/get/get-trail-markdown.txt b/cmd/kosli/testdata/output/get/get-trail-markdown.txt index ec3072785..958c83398 100644 --- a/cmd/kosli/testdata/output/get/get-trail-markdown.txt +++ b/cmd/kosli/testdata/output/get/get-trail-markdown.txt @@ -7,6 +7,20 @@ | 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 | From b278f14af4017388035bee14cfc83a7bb55c32e6 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sun, 14 Jun 2026 21:45:18 +0200 Subject: [PATCH 08/10] fix(get trail): exact-compare markdown golden and use tagged switch CI surfaced two issues with the markdown slice: - The golden-file test helper compares each line as a regex, so the markdown links ([name](url)) and query strings (?attestation_id=) were parsed as regex and failed (invalid char class range 'i-b' from [cli-build-1]). Add a goldenFileExact test option that compares the file byte-for-byte (via the existing compareFileBytes), and use it for the deterministic markdown output. The regex-based goldenFile remains for output with varying parts like timestamps. - Lint (staticcheck QF1002): mdAttestationCompliance now uses a tagged switch on status. Refs #904 Signed-off-by: Mike Long --- cmd/kosli/getTrail.go | 4 ++-- cmd/kosli/getTrail_test.go | 6 +++--- cmd/kosli/testHelpers.go | 10 +++++++++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index 811bc5aad..99969f605 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -279,8 +279,8 @@ func writeAttestationTable(b *strings.Builder, attestations []interface{}, trail // not expected by the template). func mdAttestationCompliance(status string, isCompliant interface{}, unexpected bool) string { var label string - switch { - case status == "MISSING": + switch status { + case "MISSING": label = "⏳ missing" default: if compliant, ok := isCompliant.(bool); ok { diff --git a/cmd/kosli/getTrail_test.go b/cmd/kosli/getTrail_test.go index 78ade5614..1c9f2605b 100644 --- a/cmd/kosli/getTrail_test.go +++ b/cmd/kosli/getTrail_test.go @@ -61,9 +61,9 @@ func (suite *GetTrailCommandTestSuite) TestGetTrailCmd() { 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), - goldenFile: "output/get/get-trail-markdown.txt", + 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", }, } 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 { From ea9e96f777be57fc14ff01c478f65de2d3c1faf5 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Mon, 15 Jun 2026 09:27:54 +0200 Subject: [PATCH 09/10] feat(get trail): mark unexpected attestations with a + suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the " — ⚠️ unexpected" suffix on unexpected attestations with a terse " +" marker (e.g. "✅ compliant +"). Refs #904 Signed-off-by: Mike Long --- cmd/kosli/getTrail.go | 2 +- cmd/kosli/getTrail_markdown_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index 99969f605..db5921f0c 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -294,7 +294,7 @@ func mdAttestationCompliance(status string, isCompliant interface{}, unexpected } } if unexpected { - label += " — ⚠️ unexpected" + label += " +" } return label } diff --git a/cmd/kosli/getTrail_markdown_test.go b/cmd/kosli/getTrail_markdown_test.go index d42c82470..f15610a7b 100644 --- a/cmd/kosli/getTrail_markdown_test.go +++ b/cmd/kosli/getTrail_markdown_test.go @@ -222,7 +222,7 @@ func TestPrintTrailAsMarkdownAttestationStatuses(t *testing.T) { 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 — ⚠️ unexpected |", trailURL)) + require.Contains(t, got, fmt.Sprintf("| [extra](%s?attestation_id=aaa-extra) | ✅ compliant + |", trailURL)) } // TestPrintTrailAsMarkdownAttestationLinks covers linking attestation events to From e16f2715df705ad996f1ecd22f54ab1edb377427 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Mon, 15 Jun 2026 09:31:55 +0200 Subject: [PATCH 10/10] feat(get trail): bracket the unexpected attestation marker as (+) Refs #904 Signed-off-by: Mike Long --- cmd/kosli/getTrail.go | 2 +- cmd/kosli/getTrail_markdown_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/kosli/getTrail.go b/cmd/kosli/getTrail.go index db5921f0c..19299af14 100644 --- a/cmd/kosli/getTrail.go +++ b/cmd/kosli/getTrail.go @@ -294,7 +294,7 @@ func mdAttestationCompliance(status string, isCompliant interface{}, unexpected } } if unexpected { - label += " +" + label += " (+)" } return label } diff --git a/cmd/kosli/getTrail_markdown_test.go b/cmd/kosli/getTrail_markdown_test.go index f15610a7b..76f734f8f 100644 --- a/cmd/kosli/getTrail_markdown_test.go +++ b/cmd/kosli/getTrail_markdown_test.go @@ -222,7 +222,7 @@ func TestPrintTrailAsMarkdownAttestationStatuses(t *testing.T) { 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)) + require.Contains(t, got, fmt.Sprintf("| [extra](%s?attestation_id=aaa-extra) | ✅ compliant (+) |", trailURL)) } // TestPrintTrailAsMarkdownAttestationLinks covers linking attestation events to