///|
/// Markdown rendering, for pull requests, issue comments and change tickets.
///
/// The tables follow GitHub flavoured Markdown: a header row, a separator row
/// and one row per record. A `|` inside a cell is escaped as `\|` and a line
/// break inside a cell becomes a space, because a table cell cannot hold one.
///|
/// Escape one table cell.
fn markdown_cell(text : String) -> String {
let builder = StringBuilder()
for character in text {
match character {
'|' => builder.write_string("\\|")
'\n' => builder.write_char(' ')
'\r' => builder.write_char(' ')
_ => builder.write_char(character)
}
}
builder.to_string()
}
///|
/// One table row, including the trailing newline.
fn markdown_row(cells : Array[String]) -> String {
let builder = StringBuilder()
builder.write_char('|')
for cell in cells {
builder.write_char(' ')
builder.write_string(markdown_cell(cell))
builder.write_string(" |")
}
builder.write_char('\n')
builder.to_string()
}
///|
/// The separator row for a table of `count` columns.
fn markdown_separator(count : Int) -> String {
let cells : Array[String] = []
for index = 0; index < count; index = index + 1 {
cells.push("---")
}
markdown_row(cells)
}
///|
/// A table with the given header, separator width and body rows.
fn markdown_table(header : Array[String], rows : Array[String]) -> String {
let builder = StringBuilder()
builder.write_string(markdown_row(header))
builder.write_string(markdown_separator(header.length()))
for row in rows {
builder.write_string(row)
}
builder.to_string()
}
///|
/// A table of every record of the plan, ordered by start minute.
///
/// An empty service and an empty dependency list render as `-`.
pub fn render_windows_markdown(plan : Plan) -> String {
let rows : Array[String] = []
for window in sort_windows_by_start(plan.windows) {
let depends = if window.depends.length() == 0 {
"-"
} else {
join(window.depends, ", ")
}
let service = if window.service == "" { "-" } else { window.service }
rows.push(
markdown_row([
window.id,
service,
window.kind.label(),
format_instant(window.start),
format_instant(window.end),
format_duration(window.duration()),
window.risk.label(),
depends,
]),
)
}
markdown_table(
["id", "service", "kind", "start", "end", "duration", "risk", "depends"],
rows,
)
}
///|
/// A table of the freezes of the plan, or `none`.
fn render_freezes_markdown(plan : Plan) -> String {
let freezes = sort_windows_by_start(plan.freezes())
if freezes.length() == 0 {
return "none\n"
}
let rows : Array[String] = []
for freeze in freezes {
rows.push(
markdown_row([
freeze.id,
if freeze.service == "" {
"-"
} else {
freeze.service
},
format_instant(freeze.start),
format_instant(freeze.end),
format_duration(freeze.duration()),
]),
)
}
markdown_table(["id", "service", "start", "end", "duration"], rows)
}
///|
/// Render a whole audit as Markdown.
///
/// The summary is a bullet list, then one section per collection. A section
/// without rows renders the single line `none` so a reader can tell "nothing
/// found" from "section dropped". The text ends with exactly one newline.
pub fn render_report_markdown(report : AuditReport) -> String {
let builder = StringBuilder()
builder.write_string("# Moon Change Window Audit\n\n")
builder.write_string("- status: " + report.status() + "\n")
builder.write_string("- changes: " + report.change_count().to_string() + "\n")
builder.write_string("- freezes: " + report.freeze_count().to_string() + "\n")
builder.write_string("- errors: " + report.error_count().to_string() + "\n")
builder.write_string(
"- warnings: " + report.warning_count().to_string() + "\n",
)
builder.write_string(
"- overlaps: " + report.overlaps.length().to_string() + "\n",
)
builder.write_string(
"- freeze conflicts: " + report.freeze_conflicts.length().to_string() + "\n",
)
builder.write_string(
"- dependency issues: " +
report.dependency_issues.length().to_string() +
"\n",
)
builder.write_string(
"- blocked: " + format_duration(report.blocked_minutes) + "\n",
)
builder.write_string("\n## Windows\n\n")
builder.write_string(render_windows_markdown(report.plan))
builder.write_string("\n## Freezes\n\n")
builder.write_string(render_freezes_markdown(report.plan))
builder.write_string("\n## Overlaps\n\n")
if report.overlaps.length() == 0 {
builder.write_string("none\n")
} else {
let rows : Array[String] = []
for overlap in report.overlaps {
rows.push(
markdown_row([
overlap.first,
overlap.second,
overlap.minutes.to_string(),
overlap.impact.label(),
]),
)
}
builder.write_string(
markdown_table(["first", "second", "minutes", "impact"], rows),
)
}
builder.write_string("\n## Dependency issues\n\n")
if report.dependency_issues.length() == 0 {
builder.write_string("none\n")
} else {
let rows : Array[String] = []
for issue in report.dependency_issues {
rows.push(
markdown_row([
issue.window_id,
issue.dependency,
issue.problem.label(),
issue.severity.label(),
issue.detail,
]),
)
}
builder.write_string(
markdown_table(
["window", "dependency", "problem", "severity", "detail"],
rows,
),
)
}
builder.write_string("\n## Diagnostics\n\n")
if report.plan.diagnostics.length() == 0 {
builder.write_string("none\n")
} else {
let rows : Array[String] = []
for diagnostic in report.plan.diagnostics {
rows.push(
markdown_row([
diagnostic.severity.label(),
diagnostic.line.to_string(),
diagnostic.code,
diagnostic.detail,
]),
)
}
builder.write_string(
markdown_table(["severity", "line", "code", "detail"], rows),
)
}
builder.to_string()
}
///|
/// Render safe slots as a Markdown table, or `none` when there are none.
///
/// The text ends with exactly one newline.
pub fn render_slots_markdown(slots : Array[Slot]) -> String {
let builder = StringBuilder()
builder.write_string(
"Safe change slots (" + slots.length().to_string() + ")\n\n",
)
if slots.length() == 0 {
builder.write_string("none\n")
} else {
let rows : Array[String] = []
for slot in slots {
rows.push(
markdown_row([
format_instant(slot.start),
format_instant(slot.end),
format_duration(slot.duration()),
]),
)
}
builder.write_string(markdown_table(["start", "end", "duration"], rows))
}
builder.to_string()
}