Skip to content

feat(get trail): add --output markdown format#953

Draft
meekrosoft wants to merge 7 commits into
mainfrom
feat/output-markdown
Draft

feat(get trail): add --output markdown format#953
meekrosoft wants to merge 7 commits into
mainfrom
feat/output-markdown

Conversation

@meekrosoft

Copy link
Copy Markdown
Contributor

What

Adds markdown as an output format for kosli get trail:

kosli get trail $TRAIL --flow $FLOW --output markdown

This is the first thin slice of an explicit, opt-in approach to #904 (CI summaries), proposed as an alternative to implicitly summarising every command in CI. See the discussion comment for the full rationale.

The idea: output formatting is already a format → renderer registry, so markdown slots in next to table/json. The user redirects it wherever they want — which works identically for both CIs and sidesteps GitLab's lack of a native job summary:

# GitHub Actions
kosli get trail $TRAIL --flow $FLOW --output markdown >> "$GITHUB_STEP_SUMMARY"

# GitLab (then expose summary.md as an artifact)
kosli get trail $TRAIL --flow $FLOW --output markdown > summary.md

How

  • printTrailAsMarkdown renders trail metadata, an optional git-commit block, and the events list as GitHub-Flavored Markdown tables.
  • mdCell escapes |/newlines so cell values can't break the table layout.
  • Extracted eventFields from eventRow so the table and markdown renderers share the event-description logic — table output is byte-identical.
  • Registered markdown in the get trail output map and updated its --output help text.

Tests

  • Golden test: get trail --output markdowntestdata/output/get/get-trail-markdown.txt (runs in the integration suite against the local server).
  • A server-free renderer unit test (TestPrintTrailAsMarkdown) feeds a fixture through printTrailAsMarkdown and asserts it matches the golden byte-for-byte — passes locally without the test server.

Scope / open questions

  • Draft to work out the approach before fanning out.
  • This slice covers get trail only. If the approach lands, next slices extend to the other read/detail commands (get artifact, get snapshot, diff snapshots, assert artifact), then docs with the CI usage examples above.
  • Intentionally does not touch write commands (attest *, report artifact) — they emit a one-line message, not structured data worth rendering.

Refs #904

🤖 Generated with Claude Code

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 <mikelong2005@gmail.com>
@claude

claude Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @meekrosoft's task in 3m 16s —— View job


Claude Code Review

Static analysis: go build ./... ✅ · go vet ./cmd/kosli/ ✅ · go mod tidy → no diff ✅ (golangci-lint not available in this runner, so the full lint suite wasn't run.)

Overall this is a clean, well-scoped slice that grew nicely since the first draft. The format → renderer registry fit is the right call, the eventFields extraction keeps table output byte-identical while sharing the description/link logic, and the test suite is genuinely thorough — escaping, nil cells, empty events, git-commit block, attestation statuses, and every link variant each have a server-free unit test. The earlier review notes (the <nil> cell rendering and the markdown-branch coverage gap) have both been addressed. Nice work.

Findings

Two low-priority items posted inline:

  1. Panic hardening on the shared eventFields path (getTrail.go:443, also :424/:455) — sha1[0:7] is length-unguarded, and ["timestamp"].(float64) / ["type"].(string) are unchecked assertions. All pre-existing (lifted from eventRow), but the extraction now makes them reachable from the markdown renderer too, so this is a natural moment to harden. Fix this →

  2. Empty attestation table for a zero-attestation artifact (getTrail.go:245-253) — cosmetic; an artifact with no attestations still emits a header + empty table when another artifact keeps the section's total > 0.

A couple of things to consider (no change requested)

  • Markdown link injection. mdCell escapes &/</>/|/newlines (good — kills raw-HTML injection), but not [ ] ( ). A value containing [x](url) survives into a rendered link, and a ] inside a trail/attestation name could also break the surrounding [name](url) construct. It's the user's own data rendered in their own CI summary, and GitHub's step-summary renderer sanitizes javascript:/HTML, so the risk is low — but worth a mention since markdown is a brand-new output surface.
  • Link insertion via strings.Replace in mdEventDescription is coupled to the exact server-side description wording ('…' for …, in '…'). It's well-tested today, but a future server-side phrasing change would silently drop the links rather than fail a test. Fine to leave; just noting the coupling.

These are all minor — nothing blocking. 👍

Comment on lines +17 to +25
raw := `{
"name": "cli-build-1",
"description": "test trail",
"compliance_state": "INCOMPLETE",
"last_modified_at": 1452902400,
"events": [
{"type": "trail_reported", "timestamp": 1452902400}
]
}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage gap: the new markdown branches aren't exercised. This fixture (and the shared golden) only covers a trail with no git_commit_info and a single trail_reported event. So three new code paths in printTrailAsMarkdown are never tested:

  • the ### Git commit block (getTrail.go:101-116)
  • the _No events._ empty-events path (getTrail.go:130-132)
  • mdCell's |/newline escaping (no value in the fixture contains either character)

Since the integration golden mirrors what SetupTest produces, those branches won't be covered there either. Consider adding a second unit case with a git_commit_info block (including a message containing | and a newline) and one with events: [] to lock in the escaping and the empty path.

Comment thread cmd/kosli/getTrail.go
Comment on lines +140 to +145
func mdCell(v interface{}) string {
s := fmt.Sprintf("%v", v)
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", "<br>")
return s
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: for a missing/null field, mdCell renders the literal string <nil> (e.g. a trail with no description → | Description | <nil> |). The table renderer has the same behavior, so this is consistent — but markdown is a fresh, user-facing surface (a rendered CI summary), and <nil> reads worse there than an empty cell. Consider normalizing nil to "":

func mdCell(v interface{}) string {
	if v == nil {
		return ""
	}
	s := fmt.Sprintf("%v", v)
	...
}

Low priority — flagging since this is new output, not a regression.

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 <br> replacement.
- mdCell(nil) rendered the literal "<nil>" 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 <mikelong2005@gmail.com>
…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 <br> dominated the
  summary.
- mdCell also escapes &, < and > so commit authors like
  "Name <email>" 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 <mikelong2005@gmail.com>
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 <mikelong2005@gmail.com>
Comment thread cmd/kosli/getTrail.go
Comment on lines 305 to 324
@@ -143,12 +318,19 @@ 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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two unchecked operations on this shared path can panic on malformed/partial payloads:

  • eventMap["timestamp"].(float64) (line 305) panics if timestamp is missing or not a number.
  • sha1[0:7] (line 324) panics if the sha is shorter than 7 chars.

Both are pre-existing (lifted verbatim from eventRow), so this PR doesn't regress anything — but extracting eventFields is a natural moment to harden them, since the markdown path now depends on it too. Low priority; flagging since the code moved.

…wn 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 <mikelong2005@gmail.com>
@meekrosoft

Copy link
Copy Markdown
Contributor Author

Adding some screenshots:

image image image

…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 <mikelong2005@gmail.com>
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 <mikelong2005@gmail.com>
Comment thread cmd/kosli/getTrail.go
eventCommitURL := ""
if commitInfo, ok := eventMap["git_commit_info"].(map[string]interface{}); ok {
if sha1, ok := commitInfo["sha1"].(string); ok {
eventCommit = sha1[0:7]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sha1[0:7] panics if the sha is shorter than 7 characters. It's guarded for type but not length. This is pre-existing (lifted from eventRow), but extracting eventFields makes it a shared path that the new markdown renderer also depends on, so it's worth hardening here:

Suggested change
eventCommit = sha1[0:7]
if sha1, ok := commitInfo["sha1"].(string); ok && len(sha1) >= 7 {
eventCommit = sha1[0:7]
}

Same class of issue a couple of lines up: eventMap["timestamp"].(float64) (line 424) and eventMap["type"].(string) (line 455) are unchecked type assertions that panic on a missing/partial payload. Low priority — flagging since the code moved and is now reachable from two renderers.

Comment thread cmd/kosli/getTrail.go
Comment on lines +245 to +253
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor/cosmetic: the artifact header (**name** — status) and its table are emitted unconditionally for every artifact in artifacts_statuses. If one artifact has zero attestations but another has some (so the overall total > 0 guard passes), that artifact still renders a header followed by an empty table (just the | | | / | --- | --- | header rows, no data). Consider skipping the table — or the whole block — when atts is empty:

atts, _ := artifact["attestations_statuses"].([]interface{})
if len(atts) == 0 {
    continue
}
fmt.Fprintf(b, "\n**%s** — %s\n\n", mdCell(name), mdComplianceState(artifact["status"]))
writeAttestationTable(b, atts, trailURL)

Low priority — depends on whether a zero-attestation artifact can appear alongside non-empty ones in practice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant