use std::sync::Arc;
use std::time::Duration;
use navi_notifier_core::{Engine, FilterContext, RuleConfig, RuleEngine};
use navi_notifier_gitlab::{GitLabSource, GitLabSourceConfig};
use serde_json::{json, Value};
#[path = "../e2e_common.rs"]
mod e2e_common;
use e2e_common::{env, env_or, json_ok, MemState};
#[tokio::main]
async fn main() {
match run().await {
Ok(()) => println!("e2e-gitlab: PASSED"),
Err(e) => {
eprintln!("e2e-gitlab: FAILED: {e}");
std::process::exit(1);
}
}
}
async fn run() -> Result<(), String> {
let project = env("E2E_GITLAB_PROJECT")?;
let viewer_token = env("E2E_GITLAB_VIEWER_TOKEN")?;
let actor_token = env("E2E_GITLAB_ACTOR_TOKEN")?;
let api = env_or("E2E_GITLAB_API", "https://gitlab.com/api/v4");
let mailpit = env_or("MAILPIT_HTTP", "http://localhost:8025");
let smtp_host = env_or("MAILPIT_SMTP_HOST", "localhost");
let smtp_port: u16 = env_or("MAILPIT_SMTP_PORT", "1025").parse().unwrap_or(1025);
let http = reqwest::Client::builder()
.user_agent("navi-e2e")
.build()
.map_err(|e| format!("build http client: {e}"))?;
let enc_project = project.replace('/', "%2F");
let viewer = whoami(&http, &api, &viewer_token).await?;
let actor = whoami(&http, &api, &actor_token).await?;
if viewer.username.eq_ignore_ascii_case(&actor.username) {
return Err(format!(
"viewer and actor are the same account (`{}`); the actor must differ so \
navi sees the review request as directed at the viewer",
viewer.username
));
}
println!(
"e2e-gitlab: viewer={} actor={} project={project}",
viewer.username, actor.username
);
let seeded = seed(&http, &api, &actor_token, &enc_project, viewer.id).await?;
let expect = format!("[{}#{}]", seeded.project_path, seeded.iid);
let verdict = verify(
&http,
&viewer_token,
&api,
&mailpit,
smtp_host,
smtp_port,
&expect,
)
.await;
teardown(&http, &api, &actor_token, &enc_project, &seeded).await;
verdict
}
struct Seeded {
branch: String,
iid: u64,
project_path: String,
}
async fn seed(
http: &reqwest::Client,
api: &str,
actor_token: &str,
enc_project: &str,
viewer_id: u64,
) -> Result<Seeded, String> {
let branch = format!("navi-e2e-{}", std::process::id());
let base = format!("{api}/projects/{enc_project}");
let project = get(http, &base, actor_token).await?;
let project_path = project["path_with_namespace"]
.as_str()
.ok_or("project response missing path_with_namespace")?
.to_string();
let default_branch = project["default_branch"]
.as_str()
.ok_or("project has no default_branch (is it empty?)")?
.to_string();
let _ = delete(
http,
&format!("{base}/repository/branches/{branch}"),
actor_token,
)
.await;
post(
http,
&format!("{base}/repository/branches?branch={branch}&ref={default_branch}"),
actor_token,
&json!({}),
)
.await?;
post(
http,
&format!(
"{base}/repository/files/navi-e2e-{}.txt",
std::process::id()
),
actor_token,
&json!({
"branch": branch,
"content": "navi e2e change\n",
"commit_message": "navi e2e: review request",
}),
)
.await?;
let mr = post(
http,
&format!("{base}/merge_requests"),
actor_token,
&json!({
"source_branch": branch,
"target_branch": default_branch,
"title": "navi e2e: review request",
"description": "Ephemeral MR from the navi e2e suite. Auto-closed after the run.",
"reviewer_ids": [viewer_id],
}),
)
.await?;
let iid = mr["iid"].as_u64().ok_or("MR response missing iid")?;
println!("e2e-gitlab: opened {project_path}!{iid}, requested review of viewer {viewer_id}");
Ok(Seeded {
branch,
iid,
project_path,
})
}
#[allow(clippy::too_many_arguments)]
async fn verify(
http: &reqwest::Client,
viewer_token: &str,
api: &str,
mailpit: &str,
smtp_host: String,
smtp_port: u16,
expect: &str,
) -> Result<(), String> {
let source = GitLabSource::new(GitLabSourceConfig {
token: viewer_token.to_string(),
api_base: Some(api.to_string()),
comment_min_age_secs: 0,
backfill: Default::default(),
})
.map_err(|e| format!("build gitlab source: {e}"))?;
let email = e2e_common::mailpit_email(smtp_host, smtp_port)?;
let engine = Engine::new(
vec![Arc::new(source)],
vec![Arc::new(email)],
vec![],
RuleEngine::new(RuleConfig::default()).expect("default rules"),
Arc::new(MemState::default()),
);
println!("e2e-gitlab: polling navi + checking Mailpit for {expect}…");
for attempt in 1..=45 {
let report = engine.run_once(FilterContext::default(), false).await;
for (src, err) in &report.source_errors {
eprintln!("e2e-gitlab: source {src} error: {err}");
}
if let Some(subject) =
e2e_common::mailpit_review_request(http, mailpit, Some(expect)).await?
{
println!("e2e-gitlab: email delivered, subject: {subject}");
return Ok(());
}
if attempt % 5 == 0 {
println!("e2e-gitlab: still waiting (attempt {attempt})…");
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
Err(format!(
"no review-request email for {expect} arrived in Mailpit within 45 polls"
))
}
async fn teardown(
http: &reqwest::Client,
api: &str,
actor_token: &str,
enc_project: &str,
seeded: &Seeded,
) {
let base = format!("{api}/projects/{enc_project}");
if let Err(e) = put(
http,
&format!("{base}/merge_requests/{}", seeded.iid),
actor_token,
&json!({ "state_event": "close" }),
)
.await
{
eprintln!("e2e-gitlab: teardown: closing MR failed: {e}");
}
if let Err(e) = delete(
http,
&format!("{base}/repository/branches/{}", seeded.branch),
actor_token,
)
.await
{
eprintln!("e2e-gitlab: teardown: deleting branch failed: {e}");
}
println!(
"e2e-gitlab: torn down {}!{}",
seeded.project_path, seeded.iid
);
}
struct Who {
id: u64,
username: String,
}
async fn whoami(http: &reqwest::Client, api: &str, token: &str) -> Result<Who, String> {
let user = get(http, &format!("{api}/user"), token).await?;
let id = user["id"].as_u64().ok_or("GET /user missing id")?;
let username = user["username"]
.as_str()
.ok_or("GET /user missing username")?
.to_string();
Ok(Who { id, username })
}
async fn get(http: &reqwest::Client, url: &str, token: &str) -> Result<Value, String> {
let resp = http
.get(url)
.header("PRIVATE-TOKEN", token)
.send()
.await
.map_err(|e| format!("GET {url}: {e}"))?;
json_ok(resp, &format!("GET {url}")).await
}
async fn post(
http: &reqwest::Client,
url: &str,
token: &str,
body: &Value,
) -> Result<Value, String> {
let resp = http
.post(url)
.header("PRIVATE-TOKEN", token)
.json(body)
.send()
.await
.map_err(|e| format!("POST {url}: {e}"))?;
json_ok(resp, &format!("POST {url}")).await
}
async fn put(
http: &reqwest::Client,
url: &str,
token: &str,
body: &Value,
) -> Result<Value, String> {
let resp = http
.put(url)
.header("PRIVATE-TOKEN", token)
.json(body)
.send()
.await
.map_err(|e| format!("PUT {url}: {e}"))?;
json_ok(resp, &format!("PUT {url}")).await
}
async fn delete(http: &reqwest::Client, url: &str, token: &str) -> Result<(), String> {
let resp = http
.delete(url)
.header("PRIVATE-TOKEN", token)
.send()
.await
.map_err(|e| format!("DELETE {url}: {e}"))?;
let status = resp.status();
if status.is_success() || status.as_u16() == 404 {
Ok(())
} else {
Err(format!(
"DELETE {url}: {status}: {}",
resp.text().await.unwrap_or_default()
))
}
}