From 2c3d0f9ebb400f66e405f64acae317567dcd3664 Mon Sep 17 00:00:00 2001 From: bobdivx <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:01:32 +0200 Subject: [PATCH] feat(github-app): P5 PR previews (isolated instance per PR, single edited comment, fork guard, cleanup) --- Cargo.lock | 1 + apps/server/Cargo.toml | 1 + apps/server/src/auto_deploy.rs | 1 + apps/server/src/db.rs | 2 + apps/server/src/github_app_events.rs | 6 +- apps/server/src/main.rs | 2 + apps/server/src/pr_previews.rs | 1073 +++++++++++++++++ apps/server/src/pr_previews_tests.rs | 231 ++++ apps/server/src/routes.rs | 23 +- apps/web/src/components/ProjectDetailPage.tsx | 14 +- .../components/ProjectPreviewsPanel.test.tsx | 44 + .../src/components/ProjectPreviewsPanel.tsx | 108 ++ apps/web/src/lib/api.ts | 21 + crates/github/src/checks.rs | 66 + 14 files changed, 1589 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/pr_previews.rs create mode 100644 apps/server/src/pr_previews_tests.rs create mode 100644 apps/web/src/components/ProjectPreviewsPanel.test.tsx create mode 100644 apps/web/src/components/ProjectPreviewsPanel.tsx diff --git a/Cargo.lock b/Cargo.lock index 36113f597..8f5452e6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -875,6 +875,7 @@ dependencies = [ "axum", "base64", "chrono", + "chrono-tz", "devforge-agent", "devforge-auth", "devforge-backup", diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index dd24d4f5d..39eccc439 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -38,6 +38,7 @@ serde_json = { workspace = true } sqlx = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } +chrono-tz = "0.10" tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } async-trait = { workspace = true } diff --git a/apps/server/src/auto_deploy.rs b/apps/server/src/auto_deploy.rs index a0f8fa200..25e15f8ae 100644 --- a/apps/server/src/auto_deploy.rs +++ b/apps/server/src/auto_deploy.rs @@ -280,6 +280,7 @@ pub(crate) async fn deploy_project( async fn tick(state: &AppState, ensure_webhooks: bool) { crate::github_app_events::purge_old_deliveries(&state.pool).await; + crate::pr_previews::sweep(state).await; let projects = sqlx::query_as::<_, Project>( r#"SELECT * FROM projects WHERE auto_deploy != 0 diff --git a/apps/server/src/db.rs b/apps/server/src/db.rs index aa23f7728..f9d8b8f57 100644 --- a/apps/server/src/db.rs +++ b/apps/server/src/db.rs @@ -1020,6 +1020,8 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { // GitHub App (P0) : schéma + scellement des PAT existants. migrate_github_app(pool).await?; + // Previews de PR (P5) : branche de tête, deployment GitHub, portée « preview » des env. + crate::pr_previews::migrate(pool).await?; Ok(()) } diff --git a/apps/server/src/github_app_events.rs b/apps/server/src/github_app_events.rs index 4bcd5b3de..e9d959ea7 100644 --- a/apps/server/src/github_app_events.rs +++ b/apps/server/src/github_app_events.rs @@ -280,8 +280,10 @@ async fn route(state: &AppState, acc: &Accepted) -> Result<&'static str, String> .map(|_| "processed") .map_err(|(_, j)| j.0.to_string()) } - // Previews de PR : phase P5. - "pull_request" => Ok("ignored_until_p5"), + "pull_request" => { + let id = acc.installation_id.ok_or("installation absente")?; + crate::pr_previews::handle_pull_request(state, id, &acc.action, &acc.payload).await + } _ => Ok("ignored"), } } diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 03657123b..75d90e549 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -44,6 +44,7 @@ mod platform_sso; #[cfg(test)] mod platform_sso_tests; mod pocket_id; +mod pr_previews; mod project_oidc; mod project_oidc_routes; mod project_pg; @@ -370,6 +371,7 @@ async fn main() -> Result<(), Box> { .merge(actions_routes::router()) .merge(git_routes::router()) .merge(github_app_routes::router()) + .merge(pr_previews::router()) .merge(github_app_events::router()) .merge(draft_routes::router()) .merge(correction::router()) diff --git a/apps/server/src/pr_previews.rs b/apps/server/src/pr_previews.rs new file mode 100644 index 000000000..87c2ad23d --- /dev/null +++ b/apps/server/src/pr_previews.rs @@ -0,0 +1,1073 @@ +//! P5 — Previews de pull requests (GitHub App). +//! +//! `pull_request.opened|synchronize|reopened` sur un dépôt lié à l'installation ⇒ une +//! instance isolée par PR (`pv{hash10}` → conteneur `df-pv{hash10}`), publiée sur +//! `pr--.` (un seul niveau sous la zone : couvert par le +//! certificat de la zone, comme la dev). `synchronize` remplace le build en cours de la +//! même PR (environnement `preview-`, concurrence 1 par PR). `closed` ⇒ nettoyage. +//! +//! Sécurité : +//! - PR de forks ignorées (code non fiable + variables du projet) : Check Run `neutral`. +//! - Les variables d'environnement de prod ne sont PAS injectées : seules celles marquées +//! « preview » (`project_env_vars.preview = true`) le sont, plus `DEVFORGE_PREVIEW=1`. +//! - Ressources limitées (`DEVFORGE_PREVIEW_MEMORY`, défaut 1g ; `DEVFORGE_PREVIEW_CPUS`, +//! défaut 1), pas de ports publiés sur l'hôte, pas de volumes de la prod. +//! - Commentaire PR unique par projet (marqueur caché), édité à chaque changement. + +use std::sync::atomic::{AtomicI64, Ordering}; + +use axum::{ + extract::{Path, State}, + http::HeaderMap, + routing::{get, post, put}, + Json, Router, +}; +use devforge_github::checks::RepoApi; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::github_checks::{Final, Ids, Reporter}; +use crate::routes::{auth_project, ApiError, DeployRunOutcome}; +use crate::state::{new_uuid, now_str, AppState, Project}; + +pub const ENV_PREFIX: &str = "preview-"; +pub const FORK_CHECK_NAME: &str = "DevForge / preview"; +/// Previews inactives depuis plus longtemps ⇒ nettoyées par le rattrapage. +pub const TTL_DAYS: i64 = 7; +const SWEEP_EVERY_SECS: i64 = 3600; +static LAST_SWEEP: AtomicI64 = AtomicI64::new(0); + +pub fn router() -> Router { + Router::new() + .route("/api/v1/projects/{uuid}/previews", get(list)) + .route("/api/v1/projects/{uuid}/previews/settings", put(settings)) + .route( + "/api/v1/projects/{uuid}/previews/{number}/redeploy", + post(redeploy), + ) + .route( + "/api/v1/projects/{uuid}/previews/{number}", + axum::routing::delete(remove), + ) +} + +// --------------------------------------------------------------------------- +// Fonctions pures +// --------------------------------------------------------------------------- + +/// Instance moteur (12 caractères : `pv` + 10 hex) — unique par (projet, PR). +pub fn preview_instance_id(project_uuid: &str, pr: i64) -> String { + let h = Sha256::digest(format!("{project_uuid}#{pr}").as_bytes()); + let hex: String = h.iter().map(|b| format!("{b:02x}")).collect(); + format!("pv{}", &hex[..10]) +} + +pub fn preview_container_name(project_uuid: &str, pr: i64) -> String { + devforge_deploy::project_container_name(&preview_instance_id(project_uuid, pr)) +} + +pub fn preview_environment(pr: i64) -> String { + format!("{ENV_PREFIX}{pr}") +} + +pub fn is_preview_env(environment: &str) -> bool { + environment.starts_with(ENV_PREFIX) +} + +/// Numéro de PR d'un environnement `preview-`. +pub fn pr_of_env(environment: &str) -> Option { + environment.strip_prefix(ENV_PREFIX)?.parse().ok() +} + +/// Minuscules, `[a-z0-9-]`, sans tirets en bord ni doublés. +pub fn slugify(raw: &str) -> String { + let mut out = String::new(); + for c in raw.trim().to_ascii_lowercase().chars() { + let c = if c.is_ascii_alphanumeric() { c } else { '-' }; + if c == '-' && (out.is_empty() || out.ends_with('-')) { + continue; + } + out.push(c); + } + out.trim_end_matches('-').to_string() +} + +/// `pr--.` ; label ≤ 63 caractères, valide DNS. +pub fn preview_fqdn(prod_fqdn: &str, apex: Option<&str>, pr: i64, slug: &str) -> Option { + let host = prod_fqdn.trim().trim_end_matches('.').to_ascii_lowercase(); + let labels: Vec<&str> = host.split('.').collect(); + if labels.len() < 2 || labels.iter().any(|l| l.is_empty()) || pr <= 0 { + return None; + } + let apex = apex + .map(|a| a.trim().trim_end_matches('.').to_ascii_lowercase()) + .filter(|a| !a.is_empty()); + let zone = if labels.len() == 2 || apex.as_deref() == Some(host.as_str()) { + host.clone() + } else { + labels[1..].join(".") + }; + let prefix = format!("pr-{pr}-"); + let mut s = slugify(slug); + if s.is_empty() { + s = slugify(labels[0]); + } + let room = 63usize.saturating_sub(prefix.len()); + let s: String = s.chars().take(room).collect(); + let label = format!("{prefix}{}", s.trim_end_matches('-')); + let label = label.trim_end_matches('-').to_string(); + Some(format!("{label}.{zone}")) +} + +pub fn preview_host_for(project: &Project, pr: i64) -> Option { + let fqdn = project + .production_url + .as_deref() + .and_then(crate::routes::fqdn_from_url)?; + let apex = Some(project.domain_apex.as_str()).filter(|a| !a.trim().is_empty()); + let slug = if project.slug.trim().is_empty() { + project.name.as_str() + } else { + project.slug.as_str() + }; + preview_fqdn(&fqdn, apex, pr, slug) +} + +pub fn marker(project_uuid: &str) -> String { + format!("") +} + +/// Heure de Paris lisible (`15/10 10:42`). +pub fn paris_time(at: chrono::DateTime) -> String { + at.with_timezone(&chrono_tz::Europe::Paris) + .format("%d/%m %H:%M") + .to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommentView { + pub project_name: String, + pub status: String, + pub url: Option, + pub sha: String, + pub updated: String, +} + +pub fn status_label(status: &str) -> &'static str { + match status { + "queued" => "⏳ En file", + "building" => "🔨 Build en cours", + "ready" => "✅ Prête", + "failed" => "❌ Échec", + "cancelled" => "⏹️ Annulée", + "deleted" => "🗑️ Preview supprimée", + _ => "…", + } +} + +/// Corps du commentaire unique (marqueur + tableau Markdown). +pub fn comment_body(project_uuid: &str, v: &CommentView) -> String { + let url = match (&v.url, v.status.as_str()) { + (_, "deleted") => "—".to_string(), + (Some(u), "ready") => format!("[{u}]({u})"), + (Some(u), _) => format!("`{u}`"), + (None, _) => "—".to_string(), + }; + let sha: String = v.sha.chars().take(7).collect(); + let name = v.project_name.replace('|', "\\|"); + format!( + "{}\n**DevForge — preview**\n\n| Projet | Statut | Preview | Commit | Mis à jour (heure Paris) |\n|---|---|---|---|---|\n| {} | {} | {} | `{}` | {} |\n", + marker(project_uuid), + name, + status_label(&v.status), + url, + sha, + v.updated + ) +} + +/// PR provenant d'un autre dépôt (fork) — ou dépôt de tête supprimé. +pub fn is_fork(payload: &Value) -> bool { + let head = payload + .pointer("/pull_request/head/repo/id") + .and_then(Value::as_i64); + let base = payload + .pointer("/pull_request/base/repo/id") + .and_then(Value::as_i64) + .or_else(|| payload.pointer("/repository/id").and_then(Value::as_i64)); + match (head, base) { + (Some(h), Some(b)) => h != b, + _ => true, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrAction { + Deploy { + number: i64, + head_sha: String, + head_ref: String, + }, + Close { + number: i64, + }, + Fork { + number: i64, + head_sha: String, + }, + Ignore, +} + +pub fn classify(action: &str, payload: &Value) -> PrAction { + let Some(number) = payload + .pointer("/pull_request/number") + .or_else(|| payload.get("number")) + .and_then(Value::as_i64) + else { + return PrAction::Ignore; + }; + let head_sha = payload + .pointer("/pull_request/head/sha") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let head_ref = payload + .pointer("/pull_request/head/ref") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + match action { + "closed" => PrAction::Close { number }, + "opened" | "synchronize" | "reopened" => { + let sha_ok = head_sha.len() == 40 && head_sha.chars().all(|c| c.is_ascii_hexdigit()); + if !sha_ok || head_ref.is_empty() { + PrAction::Ignore + } else if is_fork(payload) { + PrAction::Fork { number, head_sha } + } else { + PrAction::Deploy { + number, + head_sha, + head_ref, + } + } + } + _ => PrAction::Ignore, + } +} + +/// Ressources d'une preview : ports hôte retirés, mémoire/CPU plafonnés. +pub fn preview_runtime( + prod: &devforge_deploy::RuntimeSpec, + memory: &str, + cpus: &str, +) -> devforge_deploy::RuntimeSpec { + let mut rt = crate::dev_env::dev_runtime(prod); + rt.memory = Some(memory.to_string()); + rt.cpus = Some(cpus.to_string()); + rt +} + +/// `docker rm -f` de l'instance (app, `-new`, services) + réseau + image. +pub fn teardown_command(project_uuid: &str, pr: i64) -> String { + let name = preview_container_name(project_uuid, pr); + let safe: String = name + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-') + .collect(); + let short = safe.trim_start_matches("df-"); + format!( + "for c in $(docker ps -a --format '{{{{.Names}}}}' | grep -E '^{safe}(-|$)'); do docker rm -f \"$c\" >/dev/null 2>&1; done; \ + docker network rm dfside-{short} >/dev/null 2>&1; \ + docker rmi {safe}:latest >/dev/null 2>&1; true" + ) +} + +// --------------------------------------------------------------------------- +// Persistance +// --------------------------------------------------------------------------- + +pub async fn migrate(pool: &sqlx::PgPool) -> Result<(), sqlx::Error> { + for sql in [ + "ALTER TABLE pr_previews ADD COLUMN head_ref TEXT", + "ALTER TABLE pr_previews ADD COLUMN github_deployment_id BIGINT", + "ALTER TABLE project_env_vars ADD COLUMN preview BOOLEAN NOT NULL DEFAULT false", + ] { + let _ = sqlx::query(sql).execute(pool).await; + } + Ok(()) +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct PreviewRow { + pub project_uuid: String, + pub pr_number: i32, + pub head_sha: String, + pub head_ref: Option, + pub url: Option, + pub deployment_uuid: Option, + pub comment_id: Option, + pub check_run_id: Option, + pub github_deployment_id: Option, + pub status: String, + pub updated_at: chrono::DateTime, +} + +const COLS: &str = "project_uuid, pr_number, head_sha, head_ref, url, deployment_uuid, comment_id, check_run_id, github_deployment_id, status, updated_at"; + +async fn get_row(pool: &sqlx::PgPool, project_uuid: &str, pr: i64) -> Option { + sqlx::query_as::<_, PreviewRow>(&format!( + "SELECT {COLS} FROM pr_previews WHERE project_uuid = $1 AND pr_number = $2" + )) + .bind(project_uuid) + .bind(pr as i32) + .fetch_optional(pool) + .await + .ok() + .flatten() +} + +async fn set_status(pool: &sqlx::PgPool, project_uuid: &str, pr: i64, status: &str) { + let _ = sqlx::query( + "UPDATE pr_previews SET status = $1, updated_at = now() WHERE project_uuid = $2 AND pr_number = $3", + ) + .bind(status) + .bind(project_uuid) + .bind(pr as i32) + .execute(pool) + .await; +} + +/// Variables marquées « preview » uniquement (jamais les secrets de prod par défaut). +pub async fn load_preview_env_file( + pool: &sqlx::PgPool, + project_uuid: &str, + pr: i64, + url: &str, +) -> Option { + let mut rows: Vec<(String, String)> = sqlx::query_as( + "SELECT key, value FROM project_env_vars WHERE project_uuid = $1 AND preview = true ORDER BY key", + ) + .bind(project_uuid) + .fetch_all(pool) + .await + .unwrap_or_default(); + rows.retain(|(k, _)| !k.starts_with("DEVFORGE_PREVIEW")); + rows.push(("DEVFORGE_PREVIEW".into(), "1".into())); + rows.push(("DEVFORGE_PREVIEW_PR".into(), pr.to_string())); + rows.push(("DEVFORGE_PREVIEW_URL".into(), url.to_string())); + Some(devforge_env::serialize_docker_env_file(&rows)) +} + +/// Projets liés à l'installation et au dépôt de l'événement, previews activées. +async fn target_projects( + pool: &sqlx::PgPool, + installation_id: i64, + payload: &Value, +) -> Vec { + let Some(repo_id) = payload.pointer("/repository/id").and_then(Value::as_i64) else { + return Vec::new(); + }; + sqlx::query_as::<_, Project>( + "SELECT * FROM projects WHERE github_installation_id = $1 AND github_repo_id = $2 AND pr_previews_enabled = true", + ) + .bind(installation_id) + .bind(repo_id) + .fetch_all(pool) + .await + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// GitHub (commentaire, check, deployment) +// --------------------------------------------------------------------------- + +async fn reporter_for( + state: &AppState, + project: &Project, + pr: i64, + url: Option, +) -> Option { + let dep = new_uuid(); + let mut r = crate::github_checks::resolve(state, project, &dep).await?; + let base = instance_base(&r.details_url); + if r.details_url.is_some() { + r.details_url = Some(format!( + "{base}/app/projects/view?uuid={}&tab=previews", + project.uuid + )); + } + r.environment = format!("Preview PR #{pr}"); + r.environment_url = url; + Some(r) +} + +fn instance_base(details: &Option) -> String { + details + .as_deref() + .and_then(|d| d.split("/app/").next()) + .unwrap_or("") + .to_string() +} + +/// Crée ou édite le commentaire unique ; retrouvé par marqueur si l'id est perdu. +async fn upsert_comment( + api: &RepoApi, + project_uuid: &str, + pr: i64, + known: Option, + body: &str, +) -> Option { + if let Some(id) = known { + if api.update_issue_comment(id, body).await.is_ok() { + return Some(id); + } + } + let mark = marker(project_uuid); + for page in 1..=10u32 { + let Ok(list) = api.list_issue_comments(pr, page).await else { + break; + }; + if let Some(id) = list + .iter() + .find(|c| { + c.get("body") + .and_then(Value::as_str) + .is_some_and(|b| b.contains(&mark)) + }) + .and_then(|c| c.get("id").and_then(Value::as_i64)) + { + return api.update_issue_comment(id, body).await.ok().map(|_| id); + } + if list.len() < 100 { + break; + } + } + match api.create_issue_comment(pr, body).await { + Ok(id) => Some(id), + Err(e) => { + tracing::warn!(project = %project_uuid, pr, "commentaire preview : {e}"); + None + } + } +} + +async fn refresh_comment(state: &AppState, project: &Project, api: &RepoApi, pr: i64) { + let Some(row) = get_row(&state.pool, &project.uuid, pr).await else { + return; + }; + let view = CommentView { + project_name: project.name.clone(), + status: row.status.clone(), + url: row.url.clone(), + sha: row.head_sha.clone(), + updated: paris_time(chrono::Utc::now()), + }; + let body = comment_body(&project.uuid, &view); + if let Some(id) = upsert_comment(api, &project.uuid, pr, row.comment_id, &body).await { + if Some(id) != row.comment_id { + let _ = sqlx::query( + "UPDATE pr_previews SET comment_id = $1 WHERE project_uuid = $2 AND pr_number = $3", + ) + .bind(id) + .bind(&project.uuid) + .bind(pr as i32) + .execute(&state.pool) + .await; + } + } +} + +// --------------------------------------------------------------------------- +// Événements +// --------------------------------------------------------------------------- + +/// Point d'entrée du webhook app-level (`pull_request`). +pub async fn handle_pull_request( + state: &AppState, + installation_id: i64, + action: &str, + payload: &Value, +) -> Result<&'static str, String> { + let act = classify(action, payload); + if act == PrAction::Ignore { + return Ok("ignored"); + } + let projects = target_projects(&state.pool, installation_id, payload).await; + if projects.is_empty() { + return Ok("no_project"); + } + for project in projects { + match &act { + PrAction::Deploy { + number, + head_sha, + head_ref, + } => { + if let Err(e) = start_preview(state, &project, *number, head_sha, head_ref).await { + tracing::warn!(project = %project.uuid, pr = number, "preview : {e}"); + } + } + PrAction::Fork { number, head_sha } => { + if let Some(r) = reporter_for(state, &project, *number, None).await { + let _ = r + .api + .completed_check_run( + FORK_CHECK_NAME, + head_sha, + "neutral", + "Previews désactivées pour les forks", + "Le code d’une PR de fork n’est pas exécuté par DevForge (il aurait accès aux ressources du projet).", + ) + .await; + } + } + PrAction::Close { number } => { + teardown(state, &project, *number, "PR fermée").await; + } + PrAction::Ignore => {} + } + } + Ok(match act { + PrAction::Fork { .. } => "fork_skipped", + PrAction::Close { .. } => "preview_removed", + _ => "preview_queued", + }) +} + +/// Upsert de la preview + déploiement détaché du `head_sha`. +pub async fn start_preview( + state: &AppState, + project: &Project, + pr: i64, + head_sha: &str, + head_ref: &str, +) -> Result { + if project.build_pack == "dockercompose" { + return Err("previews indisponibles pour les projets docker compose".into()); + } + let host = + preview_host_for(project, pr).ok_or("URL de production absente : pas d’hôte preview")?; + let url = format!("https://{host}"); + sqlx::query( + r#"INSERT INTO pr_previews (project_uuid, pr_number, head_sha, head_ref, url, status, updated_at) + VALUES ($1, $2, $3, $4, $5, 'queued', now()) + ON CONFLICT (project_uuid, pr_number) DO UPDATE + SET head_sha = EXCLUDED.head_sha, head_ref = EXCLUDED.head_ref, + url = EXCLUDED.url, status = 'queued', updated_at = now()"#, + ) + .bind(&project.uuid) + .bind(pr as i32) + .bind(head_sha) + .bind(head_ref) + .bind(&url) + .execute(&state.pool) + .await + .map_err(|e| e.to_string())?; + + let dep_uuid = new_uuid(); + let now = now_str(); + sqlx::query( + r#"INSERT INTO deployments ( + uuid, project_id, status, git_sha, git_message, logs, finished_at, created_at, updated_at, environment + ) VALUES ($1, $2, 'queued', $3, $4, $5, NULL, $6, $7, $8)"#, + ) + .bind(&dep_uuid) + .bind(project.id) + .bind(head_sha.chars().take(12).collect::()) + .bind(format!("Preview PR #{pr}")) + .bind(format!("[devforge] preview PR #{pr} — démarrage…\n")) + .bind(&now) + .bind(&now) + .bind(preview_environment(pr)) + .execute(&state.pool) + .await + .map_err(|e| e.to_string())?; + let _ = sqlx::query( + "UPDATE pr_previews SET deployment_uuid = $1 WHERE project_uuid = $2 AND pr_number = $3", + ) + .bind(&dep_uuid) + .bind(&project.uuid) + .bind(pr as i32) + .execute(&state.pool) + .await; + + let (st, p, d, sha, r, u) = ( + state.clone(), + project.clone(), + dep_uuid.clone(), + head_sha.to_string(), + head_ref.to_string(), + url, + ); + tokio::spawn(async move { + finish_preview(&st, &p, pr, &d, &sha, &r, &u).await; + }); + Ok(dep_uuid) +} + +async fn finish_preview( + state: &AppState, + project: &Project, + pr: i64, + dep_uuid: &str, + head_sha: &str, + head_ref: &str, + url: &str, +) { + let reporter = reporter_for(state, project, pr, Some(url.to_string())).await; + let mut ids = Ids::default(); + if let Some(r) = &reporter { + refresh_comment(state, project, &r.api, pr).await; + ids = r.queued(head_sha, dep_uuid).await; + let _ = sqlx::query( + "UPDATE pr_previews SET check_run_id = $1, github_deployment_id = $2 WHERE project_uuid = $3 AND pr_number = $4", + ) + .bind(ids.check_run_id) + .bind(ids.deployment_id) + .bind(&project.uuid) + .bind(pr as i32) + .execute(&state.pool) + .await; + r.in_progress(&ids).await; + } + set_status(&state.pool, &project.uuid, pr, "building").await; + + let outcome = run_preview_deploy(state, project, pr, dep_uuid, head_sha, head_ref, url).await; + let result = &outcome.result; + let sha = result + .git_sha + .clone() + .unwrap_or_else(|| head_sha.chars().take(12).collect()); + let (status, row_status) = if outcome.cancelled { + ("cancelled", None) + } else if result.ok { + ("success", Some("ready")) + } else { + ("failed", Some("failed")) + }; + let summary = (!result.ok && !outcome.cancelled).then(|| { + devforge_deploy::parse_deploy_error_fr(&result.logs) + .map(|e| e.summary) + .unwrap_or_else(|| "Échec de la preview".into()) + }); + let _ = crate::deploy_queue::finalize_if_active( + &state.pool, + dep_uuid, + status, + &sha, + &result.logs, + summary.as_deref(), + None, + None, + ) + .await; + if outcome.cancelled { + // Remplacé par un build plus récent de la même PR : il reprend la main. + if let (Some(r), true) = ( + &reporter, + ids.check_run_id.is_some() || ids.deployment_id.is_some(), + ) { + r.finish(&ids, &Final::Cancelled).await; + } + return; + } + if result.ok { + if let Err(e) = state.proxy.ensure_traefik().await { + tracing::error!(error = %e, project = %project.uuid, "preview : ensure Traefik"); + } + } + if let Some(s) = row_status { + set_status(&state.pool, &project.uuid, pr, s).await; + } + if let Some(r) = &reporter { + let fin = if result.ok { + Final::Success { + summary: format!("Preview en ligne : {url}"), + } + } else { + crate::github_checks::failure_output(&result.logs, &[]) + }; + r.finish(&ids, &fin).await; + refresh_comment(state, project, &r.api, pr).await; + } +} + +#[allow(clippy::too_many_arguments)] +async fn run_preview_deploy( + state: &AppState, + project: &Project, + pr: i64, + deployment_uuid: &str, + head_sha: &str, + head_ref: &str, + url: &str, +) -> DeployRunOutcome { + let instance = preview_instance_id(&project.uuid, pr); + let fail = |msg: String| DeployRunOutcome { + result: devforge_deploy::DeployResult { + ok: false, + git_sha: None, + logs: format!("[devforge] {msg}\n"), + }, + cancelled: false, + }; + let Some(host) = crate::routes::fqdn_from_url(url) else { + return fail("URL de preview invalide".into()); + }; + { + let _start = state.deploy_queue.lock_project_start(project.id).await; + let _ = state.deploy_queue.register_cancel(deployment_uuid).await; + let superseded = crate::deploy_queue::supersede_in_progress( + &state.deploy_queue, + &state.pool, + project.id, + deployment_uuid, + ) + .await; + if !superseded.is_empty() { + let server = project.server_id.as_deref().unwrap_or("default"); + state.deploy.abort_in_flight_swap(&instance, server).await; + } + } + let pat: Option = + sqlx::query_as::<_, (String,)>("SELECT github_token FROM instance_settings WHERE id = 1") + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .map(|(t,)| crate::secret_box::open_compat(&t)) + .filter(|t| !t.trim().is_empty()); + let deploy_tok = crate::github_auth::deploy_token(state, &project.uuid, pat).await; + let env_file = load_preview_env_file(&state.pool, &project.uuid, pr, url).await; + let current = project + .server_id + .clone() + .unwrap_or_else(|| "default".into()); + let server_id = crate::cluster_routes::ensure_live_server_id(state, ¤t).await; + let port = project.port.clamp(1, 65535) as u16; + let settings = crate::sso::load_sso_settings(&state.pool).await; + let fwd = if crate::sso::should_protect_project(&settings, project) { + settings.effective_forward_auth_address() + } else { + None + }; + let labels = + devforge_deploy::docker::traefik_labels(&instance, &host, "/", port, fwd.as_deref()); + let memory = std::env::var("DEVFORGE_PREVIEW_MEMORY").unwrap_or_else(|_| "1g".into()); + let cpus = std::env::var("DEVFORGE_PREVIEW_CPUS").unwrap_or_else(|_| "1".into()); + let req = devforge_deploy::DeployRequest { + project_uuid: instance.clone(), + server_id: server_id.clone(), + // Vide → `data/applications/{instance}` : jamais le checkout de la prod. + workdir: String::new(), + git_repository: project.git_repository.clone().unwrap_or_default(), + git_branch: head_ref.to_string(), + build_pack: if project.build_pack.is_empty() { + "nixpacks".into() + } else { + project.build_pack.clone() + }, + port, + base_directory: if project.base_directory.is_empty() { + "/".into() + } else { + project.base_directory.clone() + }, + docker_compose_location: None, + dockerfile_path: project.dockerfile_path.clone(), + docker_build_context: project.docker_build_context.clone(), + publish_directory: project.publish_directory.clone(), + is_static: project.is_static != 0, + github_token: deploy_tok.token, + github_token_ephemeral: deploy_tok.ephemeral, + pin_sha: Some(head_sha.to_string()), + env_file, + proxy_labels: Some(labels), + gpu_nvidia: false, + gpu_dri: false, + group_network: None, + group_alias: None, + volumes: Vec::new(), + runtime: preview_runtime( + &devforge_deploy::RuntimeSpec::from_json(&project.runtime_json).unwrap_or_default(), + &memory, + &cpus, + ), + }; + let deploy = state.deploy.clone(); + let outcome = crate::deploy_queue::run_in_node_slot( + &state.deploy_queue, + &state.pool, + &server_id, + deployment_uuid, + move || { + let deploy = deploy.clone(); + async move { deploy.deploy(&req).await } + }, + ) + .await; + state.deploy_queue.unregister_cancel(deployment_uuid).await; + match outcome { + crate::deploy_queue::SlotOutcome::Cancelled => { + state + .deploy + .abort_in_flight_swap(&instance, &server_id) + .await; + DeployRunOutcome { + result: devforge_deploy::DeployResult { + ok: false, + git_sha: None, + logs: "[devforge] Preview annulée : remplacée par un build plus récent\n" + .into(), + }, + cancelled: true, + } + } + crate::deploy_queue::SlotOutcome::Completed(mut result) => { + if result.ok { + match crate::dns::sync_fqdn(state, &host, &server_id).await { + Ok(()) => result.logs.push_str(&format!("[dns] {host} OK\n")), + Err(e) => result.logs.push_str(&format!("[dns] {host} : {e}\n")), + } + result + .logs + .push_str(&format!("[devforge] Preview en ligne : https://{host}\n")); + } + DeployRunOutcome { + result, + cancelled: false, + } + } + } +} + +/// Supprime conteneur + route DNS, deployment `inactive`, commentaire « Preview supprimée ». +pub async fn teardown(state: &AppState, project: &Project, pr: i64, reason: &str) -> Value { + let now = now_str(); + let active: Vec<(String,)> = sqlx::query_as( + r#"UPDATE deployments + SET status = 'cancelled', logs = COALESCE(logs, '') || $1, finished_at = $2, updated_at = $2 + WHERE project_id = $3 AND environment = $4 + AND status IN ('queued', 'running', 'building', 'pending', 'deploying') + RETURNING uuid"#, + ) + .bind(format!("\n[devforge] preview supprimée ({reason})\n")) + .bind(&now) + .bind(project.id) + .bind(preview_environment(pr)) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + for (u,) in &active { + state.deploy_queue.signal_cancel(u).await; + } + let server = project + .server_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("default") + .to_string(); + let docker = match state + .deploy + .executor() + .exec(&server, "", &teardown_command(&project.uuid, pr), 120) + .await + { + Ok(r) => json!({"ok": r.ok}), + Err(e) => json!({"ok": false, "error": e.to_string()}), + }; + let row = get_row(&state.pool, &project.uuid, pr).await; + let host = row + .as_ref() + .and_then(|r| r.url.as_deref()) + .and_then(crate::routes::fqdn_from_url) + .or_else(|| preview_host_for(project, pr)); + if let Some(h) = &host { + crate::dns::remove_fqdn(state, h).await; + } + if row.is_some() { + set_status(&state.pool, &project.uuid, pr, "deleted").await; + if let Some(r) = reporter_for(state, project, pr, None).await { + if let Some(dep) = row.as_ref().and_then(|r| r.github_deployment_id) { + let _ = r + .api + .create_deployment_status(dep, "inactive", None, None) + .await; + } + refresh_comment(state, project, &r.api, pr).await; + } + } + json!({"cancelled_deployments": active.len(), "docker": docker, "host": host}) +} + +/// Rattrapage (au plus 1×/h depuis `auto_deploy::tick`) : PR fermées ou previews +/// inactives depuis plus de 7 jours ⇒ nettoyage. +pub async fn sweep(state: &AppState) { + let now = chrono::Utc::now().timestamp(); + let last = LAST_SWEEP.load(Ordering::Relaxed); + if now - last < SWEEP_EVERY_SECS { + return; + } + LAST_SWEEP.store(now, Ordering::Relaxed); + let rows: Vec = sqlx::query_as(&format!( + "SELECT {COLS} FROM pr_previews WHERE status <> 'deleted'" + )) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + for row in rows { + let Some(project) = crate::wake_gate::project_by_uuid(state, &row.project_uuid).await + else { + let _ = sqlx::query("UPDATE pr_previews SET status = 'deleted' WHERE project_uuid = $1 AND pr_number = $2") + .bind(&row.project_uuid) + .bind(row.pr_number) + .execute(&state.pool) + .await; + continue; + }; + let pr = row.pr_number as i64; + let stale = chrono::Utc::now() - row.updated_at > chrono::Duration::days(TTL_DAYS); + let closed = match reporter_for(state, &project, pr, None).await { + Some(r) => r + .api + .pull_state(pr) + .await + .map(|s| s == "closed") + .unwrap_or(false), + None => false, + }; + if stale || closed { + teardown( + state, + &project, + pr, + if closed { + "PR fermée" + } else { + "inactive > 7 j" + }, + ) + .await; + } + } +} + +// --------------------------------------------------------------------------- +// API (onglet « Previews ») +// --------------------------------------------------------------------------- + +async fn list( + State(state): State, + headers: HeaderMap, + Path(uuid): Path, +) -> Result, ApiError> { + let (_u, _w, project) = auth_project(&state, &headers, &uuid).await?; + let enabled: bool = + sqlx::query_as::<_, (bool,)>("SELECT pr_previews_enabled FROM projects WHERE uuid = $1") + .bind(&project.uuid) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .map(|(b,)| b) + .unwrap_or(false); + let linked: bool = sqlx::query_as::<_, (Option,)>( + "SELECT github_installation_id FROM projects WHERE uuid = $1", + ) + .bind(&project.uuid) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .and_then(|(i,)| i) + .is_some(); + let rows: Vec = sqlx::query_as(&format!( + "SELECT {COLS} FROM pr_previews WHERE project_uuid = $1 ORDER BY updated_at DESC LIMIT 100" + )) + .bind(&project.uuid) + .fetch_all(&state.pool) + .await + .map_err(ApiError::from)?; + let previews: Vec = rows + .iter() + .map(|r| { + json!({ + "pr_number": r.pr_number, + "head_sha": r.head_sha, + "head_ref": r.head_ref, + "url": r.url, + "status": r.status, + "deployment_uuid": r.deployment_uuid, + "check_run_id": r.check_run_id, + "updated_at": r.updated_at.to_rfc3339(), + }) + }) + .collect(); + Ok(Json(json!({ + "ok": true, + "enabled": enabled, + "linked": linked, + "previews": previews, + }))) +} + +#[derive(Deserialize)] +struct SettingsBody { + enabled: bool, +} + +async fn settings( + State(state): State, + headers: HeaderMap, + Path(uuid): Path, + Json(body): Json, +) -> Result, ApiError> { + let (_u, _w, project) = auth_project(&state, &headers, &uuid).await?; + sqlx::query("UPDATE projects SET pr_previews_enabled = $1 WHERE uuid = $2") + .bind(body.enabled) + .bind(&project.uuid) + .execute(&state.pool) + .await + .map_err(ApiError::from)?; + Ok(Json(json!({"ok": true, "enabled": body.enabled}))) +} + +async fn redeploy( + State(state): State, + headers: HeaderMap, + Path((uuid, number)): Path<(String, i64)>, +) -> Result, ApiError> { + let (_u, _w, project) = auth_project(&state, &headers, &uuid).await?; + let row = get_row(&state.pool, &project.uuid, number) + .await + .ok_or_else(|| ApiError::message("Preview introuvable."))?; + let head_ref = row + .head_ref + .clone() + .filter(|r| !r.is_empty()) + .ok_or_else(|| ApiError::message("Branche de la PR inconnue."))?; + let dep = start_preview(&state, &project, number, &row.head_sha, &head_ref) + .await + .map_err(ApiError::message)?; + Ok(Json(json!({"ok": true, "deployment_uuid": dep}))) +} + +async fn remove( + State(state): State, + headers: HeaderMap, + Path((uuid, number)): Path<(String, i64)>, +) -> Result, ApiError> { + let (_u, _w, project) = auth_project(&state, &headers, &uuid).await?; + if get_row(&state.pool, &project.uuid, number).await.is_none() { + return Err(ApiError::message("Preview introuvable.")); + } + let res = teardown(&state, &project, number, "supprimée depuis DevForge").await; + Ok(Json(json!({"ok": true, "result": res}))) +} + +#[cfg(test)] +#[path = "pr_previews_tests.rs"] +mod tests; diff --git a/apps/server/src/pr_previews_tests.rs b/apps/server/src/pr_previews_tests.rs new file mode 100644 index 000000000..f75305566 --- /dev/null +++ b/apps/server/src/pr_previews_tests.rs @@ -0,0 +1,231 @@ +use super::*; +use serde_json::json; + +fn pr_payload(action: &str, number: i64, head_repo: i64, base_repo: i64, sha: &str) -> Value { + json!({ + "action": action, + "number": number, + "repository": {"id": base_repo, "full_name": "bobdivx/template-studio"}, + "pull_request": { + "number": number, + "head": {"sha": sha, "ref": "feat/x", "repo": {"id": head_repo}}, + "base": {"ref": "main", "repo": {"id": base_repo}}, + }, + }) +} + +const SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + +#[test] +fn host_is_dns_valid_and_one_level_under_zone() { + let h = preview_fqdn("popcornn.jeser.app", Some("jeser.app"), 12, "popcornn").unwrap(); + assert_eq!(h, "pr-12-popcornn.jeser.app"); + let apex = preview_fqdn("example.com", None, 3, "Mon Projet!").unwrap(); + assert_eq!(apex, "pr-3-mon-projet.example.com"); + let long = "x".repeat(200); + let h = preview_fqdn("a.jeser.app", Some("jeser.app"), 99999, &long).unwrap(); + let label = h.split('.').next().unwrap(); + assert!(label.len() <= 63, "{label}"); + assert!(label + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); + assert!(!label.ends_with('-')); + assert_eq!(preview_fqdn("localhost", None, 1, "a"), None); + assert_eq!(preview_fqdn("a.b.c", None, 0, "a"), None); +} + +#[test] +fn slugify_cleans() { + assert_eq!(slugify(" Hello__World--2 "), "hello-world-2"); + assert_eq!(slugify("---"), ""); +} + +#[test] +fn instance_is_unique_per_pr_and_12_chars() { + let a = preview_instance_id("02792add-bf8d-41a3-8761-3ad7f75ae6f8", 1); + let b = preview_instance_id("02792add-bf8d-41a3-8761-3ad7f75ae6f8", 2); + let c = preview_instance_id("4e528e23-f69e-4758-bd17-4e6378374dc7", 1); + assert_eq!(a.len(), 12); + assert!(a.starts_with("pv")); + assert!(a != b && a != c); + // Jamais en collision avec une prod (`df-`, hex) ni une dev (`df-dv…`). + assert!(preview_container_name("02792add-bf8d-41a3-8761-3ad7f75ae6f8", 1).starts_with("df-pv")); + assert_eq!(preview_environment(7), "preview-7"); + assert_eq!(pr_of_env("preview-7"), Some(7)); + assert!(is_preview_env("preview-7") && !is_preview_env("production") && !is_preview_env("dev")); +} + +#[test] +fn classify_actions() { + assert_eq!( + classify("opened", &pr_payload("opened", 4, 1, 1, SHA)), + PrAction::Deploy { + number: 4, + head_sha: SHA.into(), + head_ref: "feat/x".into() + } + ); + assert!(matches!( + classify("synchronize", &pr_payload("synchronize", 4, 1, 1, SHA)), + PrAction::Deploy { .. } + )); + assert_eq!( + classify("closed", &pr_payload("closed", 4, 1, 1, SHA)), + PrAction::Close { number: 4 } + ); + assert_eq!( + classify("labeled", &pr_payload("labeled", 4, 1, 1, SHA)), + PrAction::Ignore + ); + assert_eq!( + classify("opened", &pr_payload("opened", 4, 1, 1, "nothex")), + PrAction::Ignore + ); +} + +#[test] +fn fork_pr_is_never_deployed() { + let p = pr_payload("opened", 5, 999, 1, SHA); + assert!(is_fork(&p)); + assert_eq!( + classify("opened", &p), + PrAction::Fork { + number: 5, + head_sha: SHA.into() + } + ); + // Dépôt de tête supprimé : traité comme un fork. + let mut gone = pr_payload("opened", 5, 1, 1, SHA); + gone["pull_request"]["head"]["repo"] = Value::Null; + assert!(is_fork(&gone)); +} + +#[test] +fn comment_has_marker_and_single_table_row() { + let v = CommentView { + project_name: "template|studio".into(), + status: "ready".into(), + url: Some("https://pr-1-template-studio.jeser.app".into()), + sha: SHA.into(), + updated: "15/10 10:42".into(), + }; + let b = comment_body("uuid-1", &v); + assert!(b.starts_with("")); + assert!(b.contains("| Projet | Statut | Preview | Commit | Mis à jour (heure Paris) |")); + assert!(b.contains( + "[https://pr-1-template-studio.jeser.app](https://pr-1-template-studio.jeser.app)" + )); + assert!(b.contains("`0123456`")); + assert!(b.contains("template\\|studio")); + assert_eq!(b.matches("\n| ").count(), 2, "en-tête + 1 ligne"); + let deleted = comment_body( + "uuid-1", + &CommentView { + status: "deleted".into(), + ..v + }, + ); + assert!(deleted.contains("Preview supprimée")); + assert!(!deleted.contains("https://")); +} + +#[test] +fn paris_time_is_local() { + let t = chrono::DateTime::parse_from_rfc3339("2026-10-15T08:30:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + assert_eq!(paris_time(t), "15/10 10:30"); +} + +#[test] +fn runtime_is_capped_and_unpublished() { + let prod = devforge_deploy::RuntimeSpec { + memory: Some("8g".into()), + ..Default::default() + }; + let rt = preview_runtime(&prod, "1g", "1"); + assert_eq!(rt.memory.as_deref(), Some("1g")); + assert_eq!(rt.cpus.as_deref(), Some("1")); + assert!(rt.ports.is_empty()); +} + +#[test] +fn teardown_targets_only_this_preview() { + let cmd = teardown_command("02792add-bf8d-41a3-8761-3ad7f75ae6f8", 3); + let name = preview_container_name("02792add-bf8d-41a3-8761-3ad7f75ae6f8", 3); + assert!(cmd.contains(&format!("'^{name}(-|$)'"))); + assert!(!cmd.contains("df-02792add")); +} + +mod comment_api { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn api(uri: &str) -> RepoApi { + RepoApi::new(uri, "ghs_testtoken123456", "bobdivx", "demo") + } + + #[tokio::test] + async fn open_then_three_synchronize_keeps_a_single_comment() { + let gh = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/bobdivx/demo/issues/7/comments")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .expect(1) + .mount(&gh) + .await; + Mock::given(method("POST")) + .and(path("/repos/bobdivx/demo/issues/7/comments")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({"id": 55}))) + .expect(1) + .mount(&gh) + .await; + Mock::given(method("PATCH")) + .and(path("/repos/bobdivx/demo/issues/comments/55")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": 55}))) + .expect(3) + .mount(&gh) + .await; + let a = api(&gh.uri()); + let id = upsert_comment(&a, "p1", 7, None, "opened").await; + assert_eq!(id, Some(55)); + for _ in 0..3 { + assert_eq!(upsert_comment(&a, "p1", 7, id, "sync").await, Some(55)); + } + } + + #[tokio::test] + async fn lost_comment_id_is_found_again_by_marker() { + let gh = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path("/repos/bobdivx/demo/issues/comments/1")) + .respond_with(ResponseTemplate::new(404)) + .mount(&gh) + .await; + Mock::given(method("GET")) + .and(path("/repos/bobdivx/demo/issues/7/comments")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + {"id": 10, "body": "hello"}, + {"id": 11, "body": format!("{}\nold", marker("p1"))}, + ]))) + .mount(&gh) + .await; + Mock::given(method("PATCH")) + .and(path("/repos/bobdivx/demo/issues/comments/11")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": 11}))) + .expect(1) + .mount(&gh) + .await; + Mock::given(method("POST")) + .and(path("/repos/bobdivx/demo/issues/7/comments")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({"id": 99}))) + .expect(0) + .mount(&gh) + .await; + assert_eq!( + upsert_comment(&api(&gh.uri()), "p1", 7, Some(1), "x").await, + Some(11) + ); + } +} diff --git a/apps/server/src/routes.rs b/apps/server/src/routes.rs index ad0491a19..3674b0c84 100644 --- a/apps/server/src/routes.rs +++ b/apps/server/src/routes.rs @@ -1861,6 +1861,8 @@ async fn cancel_deployment( // Le conteneur temporaire à nettoyer dépend de l'environnement (prod ≠ dev). let instance = if crate::dev_env::is_dev(&dep.environment) { crate::dev_env::dev_instance_id(&project.uuid) + } else if let Some(pr) = crate::pr_previews::pr_of_env(&dep.environment) { + crate::pr_previews::preview_instance_id(&project.uuid, pr) } else { project.uuid.clone() }; @@ -3278,10 +3280,29 @@ pub fn resume_deploy_queue(state: AppState) { .await .ok() .flatten(); - if env.is_some_and(|(e,)| crate::dev_env::is_dev(&e)) { + if env.as_ref().is_some_and(|(e,)| crate::dev_env::is_dev(e)) { crate::dev_env::finish_dev_deploy(&state, &project, &uuid).await; continue; } + if env + .as_ref() + .is_some_and(|(e,)| crate::pr_previews::is_preview_env(e)) + { + // Preview interrompue par un redémarrage : relancée par le prochain push + // ou depuis l'onglet « Previews » (jamais rejouée comme une prod). + let _ = crate::deploy_queue::finalize_if_active( + &state.pool, + &uuid, + "failed", + "unknown", + "[devforge] preview interrompue par un redémarrage\n", + Some("Preview interrompue : redéploie-la depuis l’onglet Previews"), + None, + None, + ) + .await; + continue; + } let outcome = run_real_deploy(&state, &project, &uuid).await; persist_resumed_deploy(&state, &project, &uuid, &outcome).await; } diff --git a/apps/web/src/components/ProjectDetailPage.tsx b/apps/web/src/components/ProjectDetailPage.tsx index 5915ad406..a92ddaf41 100644 --- a/apps/web/src/components/ProjectDetailPage.tsx +++ b/apps/web/src/components/ProjectDetailPage.tsx @@ -19,6 +19,7 @@ import { RuntimeLogsPanel } from './RuntimeLogsPanel'; import { ProjectSpecsModal } from './ProjectSpecsModal'; import { ProjectActionsPanel } from './ProjectActionsPanel'; import { ProjectGitPanel } from './ProjectGitPanel'; +import { ProjectPreviewsPanel } from './ProjectPreviewsPanel'; import { ProjectOidcPanel } from './ProjectOidcPanel'; import { EnvBadge, ProjectBranchesPanel, isDevDeployment } from './ProjectBranchesPanel'; import { ProjectGroupPanel, ProjectGroupSuggest } from './GroupPage'; @@ -73,6 +74,7 @@ type Tab = | 'env' | 'backups' | 'crons' + | 'previews' | 'settings'; type Props = { uuid?: string; tab?: Tab }; @@ -99,6 +101,7 @@ function readQuery(): { uuid: string; tab: Tab; builder?: boolean; agent?: strin 'env', 'backups', 'crons', + 'previews', 'settings', ]; return { @@ -443,6 +446,7 @@ export function ProjectDetailPage(props: Props) { {tab === 'env' && } {tab === 'backups' && } {tab === 'crons' && } + {tab === 'previews' && } {tab === 'domains' && ( data), + setProjectPreviews: vi.fn(async (_u, enabled) => ({ ok: true, enabled })), + redeployPreview: vi.fn(async () => ({ ok: true })), + deletePreview: vi.fn(async () => ({ ok: true })), + }; +} + +const base: PreviewsResponse = { ok: true, enabled: true, linked: true, previews: [] }; + +describe('ProjectPreviewsPanel', () => { + it('état vide', async () => { + const r = render(); + expect(await r.findByText('Aucune preview pour l’instant.')).toBeTruthy(); + }); + + it('liste, redéploie et supprime', async () => { + const c = client({ + ...base, + previews: [{ pr_number: 4, head_sha: '0123456789', status: 'ready', url: 'https://pr-4-x.jeser.app', updated_at: '' }], + }); + const r = render(); + expect(await r.findByText('https://pr-4-x.jeser.app')).toBeTruthy(); + fireEvent.click(r.getByText('Redéployer')); + await waitFor(() => expect(c.redeployPreview).toHaveBeenCalledWith('p', 4)); + fireEvent.click(r.getByText('Supprimer')); + await waitFor(() => expect(c.deletePreview).toHaveBeenCalledWith('p', 4)); + }); + + it('bascule et signale un projet non relié', async () => { + const c = client({ ...base, linked: false }); + const r = render(); + const box = (await r.findByRole('checkbox')) as HTMLInputElement; + expect(r.getByText(/pas encore relié/)).toBeTruthy(); + fireEvent.change(box, { target: { checked: false } }); + await waitFor(() => expect(c.setProjectPreviews).toHaveBeenCalledWith('p', false)); + }); +}); diff --git a/apps/web/src/components/ProjectPreviewsPanel.tsx b/apps/web/src/components/ProjectPreviewsPanel.tsx new file mode 100644 index 000000000..3d61b42f9 --- /dev/null +++ b/apps/web/src/components/ProjectPreviewsPanel.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'preact/hooks'; +import { api, type PreviewsResponse } from '../lib/api'; + +export type PreviewsClient = { + projectPreviews: (uuid: string) => Promise; + setProjectPreviews: (uuid: string, enabled: boolean) => Promise<{ ok: boolean; enabled: boolean }>; + redeployPreview: (uuid: string, pr: number) => Promise<{ ok: boolean }>; + deletePreview: (uuid: string, pr: number) => Promise<{ ok: boolean }>; +}; + +const LABELS: Record = { + queued: 'En file', + building: 'Build en cours', + ready: 'Prête', + failed: 'Échec', + cancelled: 'Annulée', + deleted: 'Supprimée', +}; + +export function previewStatusLabel(s: string): string { + return LABELS[s] ?? s; +} + +/** Onglet « Previews » : une instance par pull request (GitHub App). */ +export function ProjectPreviewsPanel({ uuid, client = api as PreviewsClient }: { uuid: string; client?: PreviewsClient }) { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + + const load = async () => { + try { + setData(await client.projectPreviews(uuid)); + setErr(null); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + useEffect(() => { + void load(); + }, [uuid]); + + const run = async (f: () => Promise) => { + setBusy(true); + try { + await f(); + await load(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + if (err && !data) return

{err}

; + if (!data) return

Chargement…

; + + return ( +
+ + {!data.linked && ( +

+ Ce projet n’est pas encore relié à l’app GitHub DevForge : les previews démarreront après la migration (Réglages + > GitHub). +

+ )} +

+ Les PR de forks ne sont jamais déployées. Seules les variables marquées « preview » sont injectées. +

+ {err &&

{err}

} + {data.previews.length === 0 ? ( +

Aucune preview pour l’instant.

+ ) : ( +
    + {data.previews.map((p) => ( +
  • + PR #{p.pr_number} + {previewStatusLabel(p.status)} + {p.head_sha.slice(0, 7)} + {p.url && p.status !== 'deleted' && ( + + {p.url} + + )} + + + {p.status !== 'deleted' && ( + + )} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 91bebafa5..d0f1f4d2e 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1441,6 +1441,16 @@ export const api = { body: JSON.stringify(body), }), githubAppStatus: () => request('/github-app'), + projectPreviews: (uuid: string) => request(`/projects/${encodeURIComponent(uuid)}/previews`), + setProjectPreviews: (uuid: string, enabled: boolean) => + request<{ ok: boolean; enabled: boolean }>(`/projects/${encodeURIComponent(uuid)}/previews/settings`, { + method: 'PUT', + body: JSON.stringify({ enabled }), + }), + redeployPreview: (uuid: string, pr: number) => + request<{ ok: boolean }>(`/projects/${encodeURIComponent(uuid)}/previews/${pr}/redeploy`, { method: 'POST' }), + deletePreview: (uuid: string, pr: number) => + request<{ ok: boolean }>(`/projects/${encodeURIComponent(uuid)}/previews/${pr}`, { method: 'DELETE' }), githubAppManifest: (body: { org?: string; workflows?: boolean } = {}) => request('/github-app/manifest', { method: 'POST', body: JSON.stringify(body) }), githubAppForget: () => @@ -2737,3 +2747,14 @@ export interface WakeLogEntry { created_at: string; last_at: string; } + +export type PreviewItem = { + pr_number: number; + head_sha: string; + head_ref?: string | null; + url?: string | null; + status: string; + deployment_uuid?: string | null; + updated_at: string; +}; +export type PreviewsResponse = { ok: boolean; enabled: boolean; linked: boolean; previews: PreviewItem[] }; diff --git a/crates/github/src/checks.rs b/crates/github/src/checks.rs index 2d039cbb3..56164d7de 100644 --- a/crates/github/src/checks.rs +++ b/crates/github/src/checks.rs @@ -221,6 +221,72 @@ impl RepoApi { .await .map(|_| ()) } + + /// Commentaires d'une issue/PR (une page, `per_page` ≤ 100). + pub async fn list_issue_comments(&self, number: i64, page: u32) -> Result, String> { + let v = self + .send(self.http.get(self.url(&format!( + "/issues/{number}/comments?per_page=100&page={page}" + )))) + .await?; + Ok(v.as_array().cloned().unwrap_or_default()) + } + + pub async fn create_issue_comment(&self, number: i64, body: &str) -> Result { + let v = self + .send( + self.http + .post(self.url(&format!("/issues/{number}/comments"))) + .json(&json!({ "body": clip(body, OUTPUT_MAX) })), + ) + .await?; + v.get("id") + .and_then(Value::as_i64) + .ok_or_else(|| "id absent".into()) + } + + pub async fn update_issue_comment(&self, comment_id: i64, body: &str) -> Result<(), String> { + self.send( + self.http + .patch(self.url(&format!("/issues/comments/{comment_id}"))) + .json(&json!({ "body": clip(body, OUTPUT_MAX) })), + ) + .await + .map(|_| ()) + } + + /// État d'une PR : `open` | `closed`. + pub async fn pull_state(&self, number: i64) -> Result { + let v = self + .send(self.http.get(self.url(&format!("/pulls/{number}")))) + .await?; + v.get("state") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "state absent".into()) + } + + /// Check Run terminé directement (ex. `neutral` pour une PR de fork). + pub async fn completed_check_run( + &self, + name: &str, + head_sha: &str, + conclusion: &str, + title: &str, + summary: &str, + ) -> Result { + let body = json!({ + "name": name, "head_sha": head_sha, "status": "completed", + "conclusion": conclusion, + "output": { "title": clip(title, 200), "summary": clip(summary, OUTPUT_MAX) }, + }); + let v = self + .send(self.http.post(self.url("/check-runs")).json(&body)) + .await?; + v.get("id") + .and_then(Value::as_i64) + .ok_or_else(|| "id absent".into()) + } } fn urlencode(s: &str) -> String {