Back to feed

sledtools/pika branch #39

pika-git-durations-1

Add compact branch CI timing summaries

Target branch: master

Merge Commit: 1a884cb9a6d3887efb0563724f230f5654b11612

branch: merged tutorial: ready ci: success
Open CI Details

Continuous Integration

CI: success

Compact status on the review page, with full logs on the CI page.

Open CI Details

Latest run #48 success

2 passed

head e98cba34ccc4773a11750f44f763fa4dcee8828a · queued 2026-03-24 16:37:32 · 2 lane(s)

queued 6s · ran 24s

check-notifications · success check-agent-contracts · success

Summary

This branch adds compact, human-readable CI timing summaries (e.g. "queued 14s · ran 31s") to the branch CI views in pika-news. It introduces a ci_timing_summary function that computes queue and execution durations from timestamp fields, a format_compact_duration helper that renders durations in a short form (seconds, minutes, hours, or days), threads an explicit now: DateTime<Utc> parameter through all view-mapping functions to make rendering deterministic and testable, and displays the resulting timing strings in both the live detail and summary HTML templates. The change includes thorough unit tests covering duration formatting, timing summary logic, and template rendering.

Tutorial Steps

Add timing_summary fields to view structs

Intent: Extend the CiSummaryRunView, CiRunView, and CiLaneView structs with an optional timing_summary field so the templates can conditionally render timing information.

Affected files: crates/pika-news/src/web.rs

Evidence
@@ -411,6 +411,7 @@ struct CiSummaryRunView {
+    timing_summary: Option<String>,
@@ -435,6 +436,7 @@ struct CiRunView {
+    timing_summary: Option<String>,
@@ -461,6 +463,7 @@ struct CiLaneView {
+    timing_summary: Option<String>,

Three view structs gain a new timing_summary: Option<String> field:

  • CiSummaryRunView (the compact summary card shown on the branch page)
  • CiRunView (the full run shown on the live CI detail page)
  • CiLaneView (each individual lane within a run)

All three use Option<String> so the templates can gracefully skip the line when no timing data is available (e.g. timestamps are missing or unparseable).

Introduce the _at wrapper pattern for deterministic time

Intent: Refactor render_branch_ci_live_html and render_branch_ci_summary_html so they delegate to new *_at variants that accept an explicit `now` parameter, enabling deterministic testing while keeping the public call sites unchanged.

Affected files: crates/pika-news/src/web.rs

Evidence
@@ -2238,6 +2241,15 @@ fn render_branch_ci_live_html(
+) -> anyhow::Result<String> {
+    render_branch_ci_live_html_at(record, ci_runs, page_notices, Utc::now())
+}
+
+fn render_branch_ci_live_html_at(
+    record: &BranchDetailRecord,
+    ci_runs: &[BranchCiRunRecord],
+    page_notices: &[PageNoticeView],
+    now: DateTime<Utc>,
@@ -2269,7 +2285,17 @@ fn render_branch_ci_summary_html(
+    render_branch_ci_summary_html_at(record, ci_runs, page_notices, review_mode, Utc::now())
+}
+
+fn render_branch_ci_summary_html_at(

A common pattern for testable time-dependent code: the existing render_branch_ci_live_html and render_branch_ci_summary_html functions become thin wrappers that call Utc::now() once and forward it to new *_at variants. This means:

  1. Production call sites are untouched — they keep calling the original function.
  2. Tests can supply a fixed now value, eliminating flaky time-dependent assertions.
  3. All downstream functions (map_ci_run_view, map_ci_lane_view, map_ci_summary_run) receive the same now, ensuring a consistent snapshot within a single render.

Thread now through view-mapping functions

Intent: Pass the explicit now timestamp down through map_ci_run_view, map_ci_lane_view, map_ci_summary_run, and lane_target_health_summary so all time-dependent computations use a single consistent clock reading.

Affected files: crates/pika-news/src/web.rs

Evidence
@@ -2313,8 +2339,14 @@ fn render_nightly_live_html(
-fn map_ci_run_view(run: BranchCiRunRecord) -> CiRunView {
+fn map_ci_run_view(run: BranchCiRunRecord, now: DateTime<Utc>) -> CiRunView {
@@ -2325,14 +2357,23 @@ fn map_ci_run_view(run: BranchCiRunRecord) -> CiRunView {
-        lanes: run.lanes.into_iter().map(map_ci_lane_view).collect(),
+        lanes: run
+            .lanes
+            .into_iter()
+            .map(|lane| map_ci_lane_view(lane, now))
+            .collect(),
@@ -2330,10 +2371,16 @@ fn map_ci_lane_view(lane: BranchCiLaneRecord) -> CiLaneView {
-fn map_ci_lane_view(lane: BranchCiLaneRecord) -> CiLaneView {
+fn map_ci_lane_view(lane: BranchCiLaneRecord, now: DateTime<Utc>) -> CiLaneView {
@@ -2406,7 +2459,7 @@ fn map_ci_summary_run(run: &BranchCiRunRecord) -> CiSummaryRunView {
-fn map_ci_summary_run(run: &BranchCiRunRecord) -> CiSummaryRunView {
+fn map_ci_summary_run(run: &BranchCiRunRecord, now: DateTime<Utc>) -> CiSummaryRunView {

Every function that previously called Utc::now() inline now receives now as a parameter:

  • map_ci_run_view — computes timing_summary for the run and forwards now to each lane.
  • map_ci_lane_view — computes timing_summary for the lane and uses now for effective_state, cooloff_active_until, and the LaneHintContext.
  • map_ci_summary_run — computes timing_summary for the summary card.
  • lane_target_health_summary — uses now for health-state checks.
  • LaneHintContext — gains a now field, replacing scattered Utc::now() calls in lane_operator_hint.

The LaneHintContext struct change at web.rs:2537 adds now: DateTime<Utc> and all usages in lane_operator_hint (cooloff check, queue age, lease expiry) switch from Utc::now() to context.now.

Implement ci_timing_summary and format_compact_duration

Intent: Add the core timing logic: a function that computes queued and running durations from CI timestamps, and a formatter that renders durations in a compact human-readable form.

Affected files: crates/pika-news/src/web.rs

Evidence
@@ -2478,7 +2537,66 @@ fn parse_ci_timestamp(raw: &str) -> Option<DateTime<Utc>> {
+fn ci_timing_summary(
+    created_at: &str,
+    started_at: Option<&str>,
+    finished_at: Option<&str>,
+    now: DateTime<Utc>,
+) -> Option<String> {
@@ +2562,0 +2562,14 @@
+fn compact_duration_part(label: &str, duration: TimeDelta) -> Option<String> {
+    if duration.num_seconds() < 0 {
+        return None;
+    }
+    Some(format!("{label} {}", format_compact_duration(duration)))
+}
@@ +2569,0 +2569,14 @@
+fn format_compact_duration(duration: TimeDelta) -> String {
+    let total_seconds = duration.num_seconds().max(0);
+    let days = total_seconds / 86_400;
+    let hours = (total_seconds % 86_400) / 3_600;
+    let minutes = (total_seconds % 3_600) / 60;
+    let seconds = total_seconds % 60;
+
+    if days > 0 {
+        format!("{days}d {hours:02}h")
+    } else if hours > 0 {
+        format!("{hours}h {minutes:02}m")
+    } else if minutes > 0 {
+        format!("{minutes}m {seconds:02}s")
+    } else {
+        format!("{seconds}s")
+    }
+}

Two new functions form the core of the feature:

ci_timing_summary

Accepts created_at, optional started_at, optional finished_at, and now. It computes up to two duration parts:

  • queued — time from created_at to started_at (or to now if still waiting and not finished).
  • ran — time from started_at to finished_at (or to now if still running).

Parts are joined with · (e.g. "queued 14s · ran 31s"). Returns None when no meaningful timing is available.

format_compact_duration

Renders a TimeDelta in the most appropriate human-readable unit:

RangeFormatExample
< 1 minute{s}s12s
< 1 hour{m}m {ss}s1m 08s
< 1 day{h}h {mm}m2h 03m
≥ 1 day{d}d {hh}h1d 04h

The compact_duration_part helper guards against negative durations (clock skew) by returning None.

Display timing summaries in the CI live template

Intent: Render the timing_summary field in the branch CI live detail page for both runs and lanes.

Affected files: crates/pika-news/templates/branch_ci_live.html

Evidence
@@ -51,6 +51,10 @@
+            {% match run.timing_summary %}
+            {% when Some with (timing_summary) %}
+            <p class="meta">{{ timing_summary }}</p>
+            {% when None %}{% endmatch %}
@@ -89,6 +93,10 @@
+            {% match lane.timing_summary %}
+            {% when Some with (timing_summary) %}
+            <p class="meta">{{ timing_summary }}</p>
+            {% when None %}{% endmatch %}

The live CI detail template (branch_ci_live.html) adds two conditional blocks using Askama's {% match %} syntax:

  1. Run level (line 54): Displays the run's timing summary right after the existing metadata line (head · queued · lane count).
  2. Lane level (line 96): Displays each lane's timing summary after its entrypoint and retry info.

Both use {% match run/lane.timing_summary %} so the <p> tag is only emitted when the Option<String> is Some.

Display timing summary in the CI summary template

Intent: Render the timing_summary for the latest run in the compact branch CI summary card.

Affected files: crates/pika-news/templates/branch_ci_summary.html

Evidence
@@ -36,6 +36,10 @@
+    {% match run.timing_summary %}
+    {% when Some with (timing_summary) %}
+    <p class="meta">{{ timing_summary }}</p>
+    {% when None %}{% endmatch %}

The summary template (branch_ci_summary.html) adds the same conditional pattern for run.timing_summary immediately after the head SHA / queue time metadata line. This gives users a quick glance at how long the latest CI run has been queued and/or running without needing to open the full detail view.

Update API handler call sites

Intent: Adapt the Forge API endpoints that were calling the old signatures of map_ci_run_view and map_ci_lane_view to pass Utc::now().

Affected files: crates/pika-news/src/web.rs

Evidence
@@ -3349,7 +3468,10 @@ async fn api_forge_branch_detail_handler(
-            ci_runs: ci_runs.into_iter().map(map_ci_run_view).collect(),
+            ci_runs: ci_runs
+                .into_iter()
+                .map(|run| map_ci_run_view(run, Utc::now()))
+                .collect(),
@@ -3387,7 +3509,7 @@ async fn api_forge_branch_logs_handler(
-                lane: map_ci_lane_view(lane),
+                lane: map_ci_lane_view(lane, Utc::now()),

Two API handlers that directly call the mapping functions needed updating:

  • api_forge_branch_detail_handler — maps all CI runs for the JSON response; now passes Utc::now() to each map_ci_run_view call.
  • api_forge_branch_logs_handler — maps a single lane for the logs response; now passes Utc::now() to map_ci_lane_view.

These are the only places where now isn't threaded from a parent _at function, since they're top-level API handlers where capturing a fresh Utc::now() at call time is appropriate.

Add comprehensive unit tests

Intent: Verify the duration formatter, timing summary logic, and template rendering with deterministic timestamps.

Affected files: crates/pika-news/src/web.rs

Evidence
@@ -5043,6 +5166,142 @@ mod tests {
+    #[test]
+    fn compact_duration_formatter_prefers_short_human_readable_forms() {
@@ +5176,0 +5176,30 @@
+    #[test]
+    fn ci_timing_summary_tracks_queued_running_and_finished_time() {
@@ +5207,0 +5207,100 @@
+    #[test]
+    fn branch_ci_templates_render_timing_summaries() {

Three new test functions cover the feature end-to-end:

compact_duration_formatter_prefers_short_human_readable_forms

Verifies the three main formatting tiers:

  • 12 seconds → "12s"
  • 68 seconds → "1m 08s"
  • 2h 3m → "2h 03m"

ci_timing_summary_tracks_queued_running_and_finished_time

Uses a fixed now of 2026-03-24T12:00:45Z and tests:

  • Queued-only (no started_at): "queued 14s"
  • Running (has started_at, no finished_at): "queued 14s · ran 31s"
  • Finished (all three timestamps): "queued 14s · ran 31s"

branch_ci_templates_render_timing_summaries

Constructs full view structs for both the summary and live templates, renders them with Askama's .render(), and asserts the timing strings appear in the HTML output. This catches template integration issues like missing fields or incorrect match arms.

Diff