Skip to main content

perfgate_github/
comment.rs

1//! Comment body rendering for GitHub PR comments.
2//!
3//! Produces rich Markdown that includes a verdict badge, metric comparison table,
4//! trend indicators, optional blame attribution, and collapsible raw data.
5
6use crate::client::COMMENT_MARKER;
7use perfgate_render::{
8    direction_str, format_metric_with_statistic, format_pct, format_value, metric_status_icon,
9    render_reason_line,
10};
11use perfgate_types::{CompareReceipt, PerfgateReport, VerdictStatus};
12
13/// Options for customizing the rendered comment.
14#[derive(Debug, Clone, Default)]
15pub struct CommentOptions {
16    /// Optional blame text to include in the comment.
17    pub blame_text: Option<String>,
18    /// Optional explain text to include in the comment.
19    pub explain_text: Option<String>,
20}
21
22/// Render a full PR comment body from a `CompareReceipt`.
23pub fn render_comment(compare: &CompareReceipt, options: &CommentOptions) -> String {
24    let mut out = String::new();
25
26    // Marker for idempotent updates
27    out.push_str(COMMENT_MARKER);
28    out.push('\n');
29
30    // Verdict header with badge
31    out.push_str(&verdict_header(compare.verdict.status));
32    out.push_str("\n\n");
33
34    // Bench name
35    out.push_str(&format!("**Bench:** `{}`\n\n", compare.bench.name));
36
37    // Summary counts
38    let counts = &compare.verdict.counts;
39    out.push_str(&format!(
40        "**Summary:** {} pass, {} warn, {} fail, {} skip\n\n",
41        counts.pass, counts.warn, counts.fail, counts.skip
42    ));
43
44    // Metric comparison table with trend indicators
45    out.push_str("| Metric | Baseline | Current | Delta | Trend | Budget | Status |\n");
46    out.push_str("|--------|--------:|--------:|------:|:-----:|--------|--------|\n");
47
48    for (metric, delta) in &compare.deltas {
49        let budget = compare.budgets.get(metric);
50        let (budget_str, direction_label) = if let Some(b) = budget {
51            (
52                format!("{:.1}%", b.threshold * 100.0),
53                direction_str(b.direction),
54            )
55        } else {
56            (String::new(), "")
57        };
58
59        let trend = trend_indicator(delta.pct);
60        let status_icon = metric_status_icon(delta.status);
61
62        out.push_str(&format!(
63            "| `{metric}` | {b} {u} | {c} {u} | {pct} | {trend} | {budget} ({dir}) | {status} |\n",
64            metric = format_metric_with_statistic(*metric, delta.statistic),
65            b = format_value(*metric, delta.baseline),
66            c = format_value(*metric, delta.current),
67            u = metric.display_unit(),
68            pct = format_pct(delta.pct),
69            trend = trend,
70            budget = budget_str,
71            dir = direction_label,
72            status = status_icon,
73        ));
74    }
75
76    // Notes section
77    if !compare.verdict.reasons.is_empty() {
78        out.push_str("\n### Notes\n\n");
79        for r in &compare.verdict.reasons {
80            out.push_str(&render_reason_line(compare, r));
81        }
82    }
83
84    // Blame attribution section
85    if let Some(blame) = &options.blame_text {
86        out.push_str("\n### Possible Causes\n\n");
87        out.push_str(blame);
88        out.push('\n');
89    }
90
91    // Explain section
92    if let Some(explain) = &options.explain_text {
93        out.push_str("\n### Diagnostic Hints\n\n");
94        out.push_str(explain);
95        out.push('\n');
96    }
97
98    // Collapsible raw data section
99    out.push_str("\n<details>\n<summary>Raw comparison data</summary>\n\n");
100    out.push_str("```json\n");
101    if let Ok(json) = serde_json::to_string_pretty(compare) {
102        out.push_str(&json);
103    }
104    out.push_str("\n```\n\n</details>\n");
105
106    // Footer
107    out.push_str("\n---\n");
108    out.push_str("*Posted by [perfgate](https://github.com/EffortlessMetrics/perfgate)*\n");
109
110    out
111}
112
113/// Render a full PR comment body from a `PerfgateReport`.
114///
115/// If the report contains a compare receipt, it delegates to `render_comment`.
116/// Otherwise, it renders a minimal summary from the report's verdict and findings.
117pub fn render_comment_from_report(report: &PerfgateReport, options: &CommentOptions) -> String {
118    if let Some(compare) = &report.compare {
119        return render_comment(compare, options);
120    }
121
122    // Minimal report when no compare receipt is available
123    let mut out = String::new();
124
125    out.push_str(COMMENT_MARKER);
126    out.push('\n');
127    out.push_str(&verdict_header(report.verdict.status));
128    out.push_str("\n\n");
129
130    out.push_str(&format!(
131        "**Summary:** {} pass, {} warn, {} fail, {} skip\n\n",
132        report.summary.pass_count,
133        report.summary.warn_count,
134        report.summary.fail_count,
135        report.summary.skip_count,
136    ));
137
138    if !report.findings.is_empty() {
139        out.push_str("### Findings\n\n");
140        for finding in &report.findings {
141            out.push_str(&format!(
142                "- **{}** ({}): {}\n",
143                finding.check_id,
144                format!("{:?}", finding.severity).to_lowercase(),
145                finding.message
146            ));
147        }
148    }
149
150    out.push_str("\n---\n");
151    out.push_str("*Posted by [perfgate](https://github.com/EffortlessMetrics/perfgate)*\n");
152
153    out
154}
155
156/// Generate a verdict header line with emoji badge.
157fn verdict_header(status: VerdictStatus) -> String {
158    match status {
159        VerdictStatus::Pass => "## :white_check_mark: perfgate: **pass**".to_string(),
160        VerdictStatus::Warn => "## :warning: perfgate: **warn**".to_string(),
161        VerdictStatus::Fail => "## :x: perfgate: **fail**".to_string(),
162        VerdictStatus::Skip => "## :fast_forward: perfgate: **skip**".to_string(),
163    }
164}
165
166/// Generate a trend indicator with arrow and percentage.
167///
168/// - Positive changes (regression for lower-is-better): red up arrow
169/// - Negative changes (improvement for lower-is-better): green down arrow
170/// - Near zero: dash
171fn trend_indicator(pct: f64) -> String {
172    let abs_pct = (pct * 100.0).abs();
173    if abs_pct < 0.5 {
174        // Essentially flat
175        return "\u{2014}".to_string(); // em dash
176    }
177
178    if pct > 0.0 {
179        format!("\u{25B2} {:.1}%", abs_pct) // black up-pointing triangle
180    } else {
181        format!("\u{25BC} {:.1}%", abs_pct) // black down-pointing triangle
182    }
183}
184
185/// Parse the `GITHUB_REPOSITORY` env var into `(owner, repo)`.
186pub fn parse_github_repository(repo_str: &str) -> Option<(String, String)> {
187    let (owner, repo) = repo_str.split_once('/')?;
188    if owner.is_empty() || repo.is_empty() {
189        return None;
190    }
191    Some((owner.to_string(), repo.to_string()))
192}
193
194/// Extract the PR number from a GitHub ref like `refs/pull/123/merge`.
195pub fn parse_pr_number_from_ref(git_ref: &str) -> Option<u64> {
196    let parts: Vec<&str> = git_ref.split('/').collect();
197    if parts.len() >= 3 && parts[0] == "refs" && parts[1] == "pull" {
198        parts[2].parse().ok()
199    } else {
200        None
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use perfgate_types::{
208        BenchMeta, Budget, CompareRef, Delta, Direction, Metric, MetricStatistic, MetricStatus,
209        ToolInfo, Verdict, VerdictCounts,
210    };
211    use std::collections::BTreeMap;
212
213    fn make_compare_receipt() -> CompareReceipt {
214        let mut budgets = BTreeMap::new();
215        budgets.insert(Metric::WallMs, Budget::new(0.2, 0.1, Direction::Lower));
216
217        let mut deltas = BTreeMap::new();
218        deltas.insert(
219            Metric::WallMs,
220            Delta {
221                baseline: 100.0,
222                current: 115.0,
223                ratio: 1.15,
224                pct: 0.15,
225                regression: 0.15,
226                statistic: MetricStatistic::Median,
227                significance: None,
228                cv: None,
229                noise_threshold: None,
230                status: MetricStatus::Warn,
231            },
232        );
233
234        CompareReceipt {
235            schema: perfgate_types::COMPARE_SCHEMA_V1.to_string(),
236            tool: ToolInfo {
237                name: "perfgate".into(),
238                version: "0.1.0".into(),
239            },
240            bench: BenchMeta {
241                name: "my-bench".into(),
242                cwd: None,
243                command: vec!["true".into()],
244                repeat: 5,
245                warmup: 0,
246                work_units: None,
247                timeout_ms: None,
248            },
249            baseline_ref: CompareRef {
250                path: None,
251                run_id: None,
252            },
253            current_ref: CompareRef {
254                path: None,
255                run_id: None,
256            },
257            budgets,
258            deltas,
259            verdict: Verdict {
260                status: VerdictStatus::Warn,
261                counts: VerdictCounts {
262                    pass: 0,
263                    warn: 1,
264                    fail: 0,
265                    skip: 0,
266                },
267                reasons: vec!["wall_ms_warn".to_string()],
268            },
269        }
270    }
271
272    #[test]
273    fn comment_contains_marker() {
274        let receipt = make_compare_receipt();
275        let body = render_comment(&receipt, &CommentOptions::default());
276        assert!(body.contains(COMMENT_MARKER));
277    }
278
279    #[test]
280    fn comment_contains_verdict_header() {
281        let receipt = make_compare_receipt();
282        let body = render_comment(&receipt, &CommentOptions::default());
283        assert!(body.contains("perfgate: **warn**"));
284    }
285
286    #[test]
287    fn comment_contains_bench_name() {
288        let receipt = make_compare_receipt();
289        let body = render_comment(&receipt, &CommentOptions::default());
290        assert!(body.contains("`my-bench`"));
291    }
292
293    #[test]
294    fn comment_contains_metric_table() {
295        let receipt = make_compare_receipt();
296        let body = render_comment(&receipt, &CommentOptions::default());
297        assert!(body.contains("| Metric |"));
298        assert!(body.contains("`wall_ms`"));
299        assert!(body.contains("+15.00%"));
300    }
301
302    #[test]
303    fn comment_contains_trend_indicator() {
304        let receipt = make_compare_receipt();
305        let body = render_comment(&receipt, &CommentOptions::default());
306        // 15% regression should show up arrow
307        assert!(body.contains("\u{25B2}"));
308    }
309
310    #[test]
311    fn comment_contains_collapsible_raw_data() {
312        let receipt = make_compare_receipt();
313        let body = render_comment(&receipt, &CommentOptions::default());
314        assert!(body.contains("<details>"));
315        assert!(body.contains("Raw comparison data"));
316        assert!(body.contains("</details>"));
317    }
318
319    #[test]
320    fn comment_contains_blame_when_provided() {
321        let receipt = make_compare_receipt();
322        let options = CommentOptions {
323            blame_text: Some("Dependency `serde` updated from 1.0 to 2.0".to_string()),
324            explain_text: None,
325        };
326        let body = render_comment(&receipt, &options);
327        assert!(body.contains("### Possible Causes"));
328        assert!(body.contains("serde"));
329    }
330
331    #[test]
332    fn comment_omits_blame_when_not_provided() {
333        let receipt = make_compare_receipt();
334        let body = render_comment(&receipt, &CommentOptions::default());
335        assert!(!body.contains("### Possible Causes"));
336    }
337
338    #[test]
339    fn comment_contains_footer() {
340        let receipt = make_compare_receipt();
341        let body = render_comment(&receipt, &CommentOptions::default());
342        assert!(body.contains("Posted by [perfgate]"));
343    }
344
345    #[test]
346    fn comment_contains_notes_section() {
347        let receipt = make_compare_receipt();
348        let body = render_comment(&receipt, &CommentOptions::default());
349        assert!(body.contains("### Notes"));
350        assert!(body.contains("wall_ms_warn"));
351    }
352
353    #[test]
354    fn trend_indicator_flat() {
355        let trend = trend_indicator(0.001); // 0.1%, below 0.5% threshold
356        assert_eq!(trend, "\u{2014}");
357    }
358
359    #[test]
360    fn trend_indicator_regression() {
361        let trend = trend_indicator(0.15); // +15%
362        assert!(trend.contains("\u{25B2}"));
363        assert!(trend.contains("15.0%"));
364    }
365
366    #[test]
367    fn trend_indicator_improvement() {
368        let trend = trend_indicator(-0.10); // -10%
369        assert!(trend.contains("\u{25BC}"));
370        assert!(trend.contains("10.0%"));
371    }
372
373    #[test]
374    fn parse_github_repository_valid() {
375        let (owner, repo) = parse_github_repository("octocat/hello-world").unwrap();
376        assert_eq!(owner, "octocat");
377        assert_eq!(repo, "hello-world");
378    }
379
380    #[test]
381    fn parse_github_repository_invalid() {
382        assert!(parse_github_repository("no-slash").is_none());
383        assert!(parse_github_repository("/repo").is_none());
384        assert!(parse_github_repository("owner/").is_none());
385    }
386
387    #[test]
388    fn parse_pr_number_from_ref_valid() {
389        assert_eq!(parse_pr_number_from_ref("refs/pull/123/merge"), Some(123));
390        assert_eq!(parse_pr_number_from_ref("refs/pull/1/head"), Some(1));
391    }
392
393    #[test]
394    fn parse_pr_number_from_ref_invalid() {
395        assert!(parse_pr_number_from_ref("refs/heads/main").is_none());
396        assert!(parse_pr_number_from_ref("refs/pull/abc/merge").is_none());
397    }
398
399    #[test]
400    fn verdict_header_variants() {
401        assert!(verdict_header(VerdictStatus::Pass).contains("pass"));
402        assert!(verdict_header(VerdictStatus::Warn).contains("warn"));
403        assert!(verdict_header(VerdictStatus::Fail).contains("fail"));
404        assert!(verdict_header(VerdictStatus::Skip).contains("skip"));
405    }
406
407    #[test]
408    fn render_comment_from_report_without_compare() {
409        let report = PerfgateReport {
410            report_type: "perfgate.report.v1".to_string(),
411            verdict: Verdict {
412                status: VerdictStatus::Pass,
413                counts: VerdictCounts {
414                    pass: 1,
415                    warn: 0,
416                    fail: 0,
417                    skip: 0,
418                },
419                reasons: vec![],
420            },
421            compare: None,
422            findings: vec![],
423            summary: perfgate_types::ReportSummary {
424                total_count: 1,
425                pass_count: 1,
426                warn_count: 0,
427                fail_count: 0,
428                skip_count: 0,
429            },
430            profile_path: None,
431        };
432
433        let body = render_comment_from_report(&report, &CommentOptions::default());
434        assert!(body.contains(COMMENT_MARKER));
435        assert!(body.contains("perfgate: **pass**"));
436        assert!(body.contains("1 pass"));
437    }
438
439    #[test]
440    fn render_comment_from_report_with_compare() {
441        let compare = make_compare_receipt();
442        let report = PerfgateReport {
443            report_type: "perfgate.report.v1".to_string(),
444            verdict: compare.verdict.clone(),
445            compare: Some(compare),
446            findings: vec![],
447            summary: perfgate_types::ReportSummary {
448                total_count: 1,
449                pass_count: 0,
450                warn_count: 1,
451                fail_count: 0,
452                skip_count: 0,
453            },
454            profile_path: None,
455        };
456
457        let body = render_comment_from_report(&report, &CommentOptions::default());
458        assert!(body.contains("`wall_ms`"));
459        assert!(body.contains("| Metric |"));
460    }
461}