feat(get trail): add --output markdown format#953
Conversation
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 finished @meekrosoft's task in 3m 16s —— View job Claude Code ReviewStatic analysis: Overall this is a clean, well-scoped slice that grew nicely since the first draft. The FindingsTwo low-priority items posted inline:
A couple of things to consider (no change requested)
These are all minor — nothing blocking. 👍 |
| raw := `{ | ||
| "name": "cli-build-1", | ||
| "description": "test trail", | ||
| "compliance_state": "INCOMPLETE", | ||
| "last_modified_at": 1452902400, | ||
| "events": [ | ||
| {"type": "trail_reported", "timestamp": 1452902400} | ||
| ] | ||
| }` |
There was a problem hiding this comment.
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 commitblock (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.
| func mdCell(v interface{}) string { | ||
| s := fmt.Sprintf("%v", v) | ||
| s = strings.ReplaceAll(s, "|", "\\|") | ||
| s = strings.ReplaceAll(s, "\n", "<br>") | ||
| return s | ||
| } |
There was a problem hiding this comment.
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>
| @@ -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] | |||
There was a problem hiding this comment.
Two unchecked operations on this shared path can panic on malformed/partial payloads:
eventMap["timestamp"].(float64)(line 305) panics iftimestampis 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>
…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>
| eventCommitURL := "" | ||
| if commitInfo, ok := eventMap["git_commit_info"].(map[string]interface{}); ok { | ||
| if sha1, ok := commitInfo["sha1"].(string); ok { | ||
| eventCommit = sha1[0:7] |
There was a problem hiding this comment.
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:
| 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.
| 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) | ||
| } |
There was a problem hiding this comment.
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.



What
Adds
markdownas an output format forkosli get trail: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 → rendererregistry, somarkdownslots in next totable/json. The user redirects it wherever they want — which works identically for both CIs and sidesteps GitLab's lack of a native job summary:How
printTrailAsMarkdownrenders trail metadata, an optional git-commit block, and the events list as GitHub-Flavored Markdown tables.mdCellescapes|/newlines so cell values can't break the table layout.eventFieldsfromeventRowso the table and markdown renderers share the event-description logic — table output is byte-identical.markdownin theget trailoutput map and updated its--outputhelp text.Tests
get trail --output markdown→testdata/output/get/get-trail-markdown.txt(runs in the integration suite against the local server).TestPrintTrailAsMarkdown) feeds a fixture throughprintTrailAsMarkdownand asserts it matches the golden byte-for-byte — passes locally without the test server.Scope / open questions
get trailonly. 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.attest *,report artifact) — they emit a one-line message, not structured data worth rendering.Refs #904
🤖 Generated with Claude Code