Skip to main content

schema_risk/
ci.rs

1//! CI/CD integration — GitHub and GitLab PR comment formatters.
2//!
3//! `schema-risk ci-report` outputs analysis results as GitHub-Flavored
4//! Markdown suitable for posting as a PR comment via `actions/github-script`
5//! or the GitLab Merge Request Notes API.
6//!
7//! ## Usage
8//! ```text
9//! schema-risk ci-report migrations/*.sql --format github-comment
10//! ```
11//!
12//! The output is printed to **stdout** so it can be captured in CI:
13//! ```yaml
14//! # .github/workflows/schema-risk.yml
15//! - name: Run SchemaRisk
16//!   id: schema_risk
17//!   run: |
18//!     schema-risk ci-report $CHANGED_FILES --format github-comment > /tmp/schema_risk_report.md
19//! ```
20
21use crate::impact::ImpactReport;
22use crate::recommendation::FixSuggestion;
23use crate::types::{MigrationReport, RiskLevel};
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26
27// ─────────────────────────────────────────────
28// Format selector
29// ─────────────────────────────────────────────
30
31/// Output format for `ci-report`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum CiFormat {
35    /// GitHub-flavored Markdown — post as a PR comment.
36    GithubComment,
37    /// GitLab-flavored Markdown — post as MR note.
38    GitlabComment,
39    /// Machine-readable JSON (same as `schema-risk analyze --output json`).
40    Json,
41}
42
43impl std::str::FromStr for CiFormat {
44    type Err = String;
45    fn from_str(s: &str) -> Result<Self, Self::Err> {
46        match s {
47            "github-comment" => Ok(CiFormat::GithubComment),
48            "gitlab-comment" => Ok(CiFormat::GitlabComment),
49            "json" => Ok(CiFormat::Json),
50            other => Err(format!(
51                "unknown CI format: '{other}' — valid: github-comment, gitlab-comment, json"
52            )),
53        }
54    }
55}
56
57// ─────────────────────────────────────────────
58// Main render function
59// ─────────────────────────────────────────────
60
61/// Generate a CI report in the requested format.
62///
63/// # Arguments
64/// - `reports`  — one `MigrationReport` per analyzed SQL file.
65/// - `fixes`    — `FixSuggestion`s keyed by the report's `file` field.
66/// - `impact`   — optional query impact report from `--scan-dir`.
67/// - `format`   — target format.
68pub fn render_ci_report(
69    reports: &[MigrationReport],
70    fixes: &HashMap<String, Vec<FixSuggestion>>,
71    impact: Option<&ImpactReport>,
72    format: CiFormat,
73) -> String {
74    match format {
75        CiFormat::GithubComment | CiFormat::GitlabComment => {
76            render_markdown(reports, fixes, impact)
77        }
78        CiFormat::Json => serde_json::to_string_pretty(reports).unwrap_or_default(),
79    }
80}
81
82// ─────────────────────────────────────────────
83// Markdown renderer
84// ─────────────────────────────────────────────
85
86fn render_markdown(
87    reports: &[MigrationReport],
88    fixes: &HashMap<String, Vec<FixSuggestion>>,
89    impact: Option<&ImpactReport>,
90) -> String {
91    let mut md = String::with_capacity(4096);
92
93    let max_risk = reports
94        .iter()
95        .map(|r| r.overall_risk)
96        .max()
97        .unwrap_or(RiskLevel::Low);
98
99    // ── Title ─────────────────────────────────────────────────────────────
100    let pg_version = reports.first().map(|r| r.pg_version).unwrap_or(14);
101    md.push_str(&format!(
102        "## 🔍 SchemaRisk — Migration Safety Report  *(PostgreSQL {})*\n\n",
103        pg_version
104    ));
105
106    // ── Risk banner ───────────────────────────────────────────────────────
107    let banner = match max_risk {
108        RiskLevel::Critical => {
109            "> [!CAUTION]\n> 🚨 **CRITICAL RISK** — one or more migrations will cause production downtime.\n> **Do not merge without a detailed review and a maintenance window plan.**"
110        }
111        RiskLevel::High => {
112            "> [!WARNING]\n> ⚠️ **HIGH RISK** — significant impact on database availability.\n> Review all findings carefully before merging."
113        }
114        RiskLevel::Medium => {
115            "> [!IMPORTANT]\n> 🔶 **MEDIUM RISK** — deploy during a low-traffic window.\n> Some operations may briefly degrade performance."
116        }
117        RiskLevel::Low => {
118            "> [!TIP]\n> ✅ **LOW RISK** — migrations look safe to deploy."
119        }
120    };
121    md.push_str(banner);
122    md.push_str("\n\n");
123
124    // ── Summary table ─────────────────────────────────────────────────────
125    md.push_str(
126        "| File | Risk | Score | Lock | Est. Duration | Breaking Changes |\n\
127         |------|:----:|------:|------|--------------:|:-----------------|\n",
128    );
129
130    for r in reports {
131        let risk_badge = risk_badge(r.overall_risk);
132        // Find worst lock across all operations
133        let lock_str = if r.operations.iter().any(|o| o.acquires_lock) {
134            "ACCESS EXCLUSIVE"
135        } else {
136            "—"
137        };
138        let duration = r
139            .estimated_lock_seconds
140            .map(format_duration)
141            .unwrap_or_else(|| "—".to_string());
142        let breaks = impact.map(|i| i.impacted_files.len()).unwrap_or(0);
143        let breaks_str = if breaks > 0 {
144            format!("⚠️ {} file(s)", breaks)
145        } else {
146            "—".to_string()
147        };
148        let file_short = short_name(&r.file);
149        md.push_str(&format!(
150            "| `{file_short}` | {risk_badge} | {} | `{lock_str}` | {duration} | {breaks_str} |\n",
151            r.score
152        ));
153    }
154    md.push('\n');
155
156    // ── Per-file detail sections ──────────────────────────────────────────
157    for r in reports {
158        render_file_section(&mut md, r, fixes, impact);
159    }
160
161    // ── Footer ────────────────────────────────────────────────────────────
162    md.push_str(
163        "---\n\
164         <sub>\n\
165         🛡️ Generated by <strong><a href=\"https://github.com/Keystones-Lab/Schema-risk\">SchemaRisk</a></strong> \
166         — Prevent dangerous database migrations before they reach production.\n\n\
167         Detects lock-heavy operations • Generates safe migration plans • Scans codebase for blast radius\n\
168         </sub>\n",
169    );
170
171    md
172}
173
174/// Render a detailed section for a single migration file.
175fn render_file_section(
176    md: &mut String,
177    r: &MigrationReport,
178    fixes: &HashMap<String, Vec<FixSuggestion>>,
179    impact: Option<&ImpactReport>,
180) {
181    let emoji = risk_emoji(r.overall_risk);
182    md.push_str(&format!("\n### {emoji} `{}`\n\n", r.file));
183
184    // ── Metadata line ──────────────────────────────────────────────────────
185    if !r.affected_tables.is_empty() {
186        let tables: Vec<String> = r.affected_tables.iter().map(|t| format!("`{t}")).collect();
187        md.push_str(&format!("**Tables affected:** {}\n\n", tables.join(", ")));
188    }
189
190    if let Some(secs) = r.estimated_lock_seconds {
191        let duration = format_duration(secs);
192        let warning = if secs > 30 {
193            " — ⚠️ **This is a long lock!**"
194        } else {
195            ""
196        };
197        md.push_str(&format!(
198            "**Estimated lock duration:** {duration}{warning}\n\n"
199        ));
200    }
201
202    // ── Operations ────────────────────────────────────────────────────────
203    if !r.operations.is_empty() {
204        md.push_str("**Operations detected:**\n\n");
205        for op in &r.operations {
206            let op_emoji = if op.risk_level >= RiskLevel::High {
207                "🚨"
208            } else if op.risk_level >= RiskLevel::Medium {
209                "⚠️"
210            } else {
211                "✅"
212            };
213            md.push_str(&format!("- {op_emoji} `{}`\n", op.description));
214            if let Some(w) = &op.warning {
215                md.push_str(&format!("  > _{w}_\n"));
216            }
217            if op.acquires_lock {
218                md.push_str("  > 🔒 Acquires **ACCESS EXCLUSIVE** table lock\n");
219            }
220        }
221        md.push('\n');
222    }
223
224    // ── Warnings from engine ──────────────────────────────────────────────
225    if !r.warnings.is_empty() {
226        md.push_str("**Warnings:**\n\n");
227        for w in &r.warnings {
228            md.push_str(&format!("- ⚠️ {w}\n"));
229        }
230        md.push('\n');
231    }
232
233    // ── Breaking changes from codebase scan ───────────────────────────────
234    if let Some(impact_report) = impact {
235        if !impact_report.impacted_files.is_empty() {
236            md.push_str("#### ⚠️ Breaking Changes — Codebase References\n\n");
237            md.push_str(
238                "The following files contain queries or code referencing \
239                 schema objects affected by this migration:\n\n",
240            );
241            for f in impact_report.impacted_files.iter().take(15) {
242                for hit in f.hits.iter().take(3) {
243                    let snippet = hit.snippet.chars().take(100).collect::<String>();
244                    md.push_str(&format!(
245                        "- [`{}:{}`]({}) — `{snippet}`\n",
246                        f.path, hit.line, f.path
247                    ));
248                }
249            }
250            if impact_report.impacted_files.len() > 15 {
251                md.push_str(&format!(
252                    "\n_... and {} more files_\n",
253                    impact_report.impacted_files.len() - 15
254                ));
255            }
256            md.push('\n');
257        }
258    }
259
260    // ── Fix suggestions ───────────────────────────────────────────────────
261    let no_fixes = Vec::new();
262    let file_fixes = fixes.get(&r.file).unwrap_or(&no_fixes);
263    if !file_fixes.is_empty() {
264        md.push_str("#### ✅ Suggested Fixes\n\n");
265        for fix in file_fixes.iter().take(6) {
266            let sev_badge = match fix.severity {
267                crate::recommendation::FixSeverity::Blocking => "🚨 BLOCKING",
268                crate::recommendation::FixSeverity::Warning => "⚠️ WARNING",
269                crate::recommendation::FixSeverity::Info => "ℹ️ INFO",
270            };
271            md.push_str(&format!(
272                "**[{}] {}** `{sev_badge}`\n\n",
273                fix.rule_id, fix.title
274            ));
275            md.push_str(&format!("{}\n\n", fix.explanation));
276
277            if let Some(sql) = &fix.fixed_sql {
278                md.push_str("```sql\n");
279                md.push_str(sql);
280                md.push_str("\n```\n\n");
281            }
282
283            if let Some(steps) = &fix.migration_steps {
284                md.push_str(
285                    "<details>\n<summary>📋 Zero-downtime migration steps</summary>\n\n```sql\n",
286                );
287                for step in steps {
288                    md.push_str(step);
289                    md.push('\n');
290                }
291                md.push_str("```\n\n</details>\n\n");
292            }
293
294            if let Some(url) = &fix.docs_url {
295                md.push_str(&format!("📖 [PostgreSQL docs]({url})\n\n"));
296            }
297        }
298    }
299
300    // ── Engine recommendations ────────────────────────────────────────────
301    if !r.recommendations.is_empty() {
302        md.push_str("#### 💡 Recommendations\n\n");
303        for rec in &r.recommendations {
304            md.push_str(&format!("- {rec}\n"));
305        }
306        md.push('\n');
307    }
308
309    // ── PG version notice ─────────────────────────────────────────────────
310    md.push_str(&format!(
311        "<sub>Analyzed against PostgreSQL {} rules. \
312         Use `--pg-version` to change target version.</sub>\n\n",
313        r.pg_version
314    ));
315
316    md.push_str("---\n");
317}
318
319// ─────────────────────────────────────────────
320// Helpers
321// ─────────────────────────────────────────────
322
323/// GitHub badge string for a risk level.
324fn risk_badge(level: RiskLevel) -> &'static str {
325    match level {
326        RiskLevel::Critical => "🚨 **CRITICAL**",
327        RiskLevel::High => "🔴 **HIGH**",
328        RiskLevel::Medium => "🟡 **MEDIUM**",
329        RiskLevel::Low => "🟢 LOW",
330    }
331}
332
333/// Single emoji for a risk level.
334fn risk_emoji(level: RiskLevel) -> &'static str {
335    match level {
336        RiskLevel::Critical => "🚨",
337        RiskLevel::High => "🔴",
338        RiskLevel::Medium => "🟡",
339        RiskLevel::Low => "🟢",
340    }
341}
342
343/// Format seconds as a human-readable duration string.
344fn format_duration(secs: u64) -> String {
345    if secs >= 3600 {
346        format!("~{}h {}m", secs / 3600, (secs % 3600) / 60)
347    } else if secs >= 60 {
348        format!("~{}m {}s", secs / 60, secs % 60)
349    } else {
350        format!("~{}s", secs)
351    }
352}
353
354/// Return the filename portion of a path, or the full path if no slash.
355fn short_name(path: &str) -> &str {
356    std::path::Path::new(path)
357        .file_name()
358        .and_then(|n| n.to_str())
359        .unwrap_or(path)
360}
361
362// ─────────────────────────────────────────────
363// Unit tests
364// ─────────────────────────────────────────────
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::types::{DetectedOperation, RiskLevel};
370
371    fn make_report(file: &str, risk: RiskLevel) -> MigrationReport {
372        MigrationReport {
373            file: file.to_string(),
374            overall_risk: risk,
375            score: risk as u32 * 25,
376            affected_tables: vec!["users".to_string()],
377            operations: vec![DetectedOperation {
378                description: "CREATE INDEX idx_users_email ON users(email)".to_string(),
379                tables: vec!["users".to_string()],
380                risk_level: risk,
381                score: risk as u32 * 25,
382                warning: Some("No CONCURRENTLY keyword".to_string()),
383                acquires_lock: true,
384                index_rebuild: true,
385            }],
386            warnings: vec!["No CONCURRENTLY".to_string()],
387            recommendations: vec!["Use CREATE INDEX CONCURRENTLY".to_string()],
388            fk_impacts: vec![],
389            estimated_lock_seconds: Some(36),
390            index_rebuild_required: true,
391            requires_maintenance_window: true,
392            analyzed_at: "2026-01-01T00:00:00Z".to_string(),
393            pg_version: 14,
394            guard_required: false,
395            guard_decisions: vec![],
396        }
397    }
398
399    #[test]
400    fn test_github_comment_contains_table_header() {
401        let reports = vec![make_report(
402            "migrations/0042_add_index.sql",
403            RiskLevel::Critical,
404        )];
405        let fixes = HashMap::new();
406        let output = render_ci_report(&reports, &fixes, None, CiFormat::GithubComment);
407        assert!(output.contains("SchemaRisk — Migration Safety Report"));
408        assert!(output.contains("PostgreSQL 14"));
409        assert!(output.contains("CRITICAL"));
410        assert!(output.contains("0042_add_index.sql"));
411    }
412
413    #[test]
414    fn test_json_output_is_valid_json() {
415        let reports = vec![make_report("test.sql", RiskLevel::Low)];
416        let fixes = HashMap::new();
417        let json = render_ci_report(&reports, &fixes, None, CiFormat::Json);
418        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
419        assert!(parsed.is_array());
420    }
421
422    #[test]
423    fn test_format_duration() {
424        assert_eq!(format_duration(0), "~0s");
425        assert_eq!(format_duration(36), "~36s");
426        assert_eq!(format_duration(90), "~1m 30s");
427        assert_eq!(format_duration(3661), "~1h 1m");
428    }
429}