1use 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#[derive(Debug, Clone, Default)]
15pub struct CommentOptions {
16 pub blame_text: Option<String>,
18 pub explain_text: Option<String>,
20}
21
22pub fn render_comment(compare: &CompareReceipt, options: &CommentOptions) -> String {
24 let mut out = String::new();
25
26 out.push_str(COMMENT_MARKER);
28 out.push('\n');
29
30 out.push_str(&verdict_header(compare.verdict.status));
32 out.push_str("\n\n");
33
34 out.push_str(&format!("**Bench:** `{}`\n\n", compare.bench.name));
36
37 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 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 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 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 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 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 out.push_str("\n---\n");
108 out.push_str("*Posted by [perfgate](https://github.com/EffortlessMetrics/perfgate)*\n");
109
110 out
111}
112
113pub 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 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
156fn 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
166fn trend_indicator(pct: f64) -> String {
172 let abs_pct = (pct * 100.0).abs();
173 if abs_pct < 0.5 {
174 return "\u{2014}".to_string(); }
177
178 if pct > 0.0 {
179 format!("\u{25B2} {:.1}%", abs_pct) } else {
181 format!("\u{25BC} {:.1}%", abs_pct) }
183}
184
185pub 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
194pub 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 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); assert_eq!(trend, "\u{2014}");
357 }
358
359 #[test]
360 fn trend_indicator_regression() {
361 let trend = trend_indicator(0.15); 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); 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}