use std::{fs::write, path::Path, process::Command, time::Duration};
use reqwest::Client;
use serde::Deserialize;
use tokio::{fs::create_dir_all, time::sleep};
#[derive(Debug, Deserialize)]
struct Release {
id: u64,
tag_name: String,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
}
fn github_env() -> (String, String, String) {
let token = std::env::var("KNOPE_INTEGRATION_GITHUB_TOKEN")
.expect("KNOPE_INTEGRATION_GITHUB_TOKEN must be set");
let owner = std::env::var("KNOPE_INTEGRATION_GITHUB_OWNER")
.expect("KNOPE_INTEGRATION_GITHUB_OWNER must be set");
let repo = std::env::var("KNOPE_INTEGRATION_GITHUB_REPO")
.expect("KNOPE_INTEGRATION_GITHUB_REPO must be set");
(token, owner, repo)
}
fn ensure_gh_installed() {
Command::new("gh")
.arg("--version")
.output()
.expect("GitHub CLI (gh) is not installed or not in PATH");
}
use super::integration_helpers::{push_branch, redact_url_credentials};
fn http_client() -> Client {
Client::builder()
.user_agent("Knope")
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to build HTTP client")
}
fn git(dir: &Path, args: &[&str]) -> std::process::Output {
Command::new("git")
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.current_dir(dir)
.output()
.expect("Failed to run git command")
}
fn assert_git(dir: &Path, args: &[&str]) {
let output = git(dir, args);
assert!(
output.status.success(),
"git {} failed:\nstdout: {}\nstderr: {}",
args.join(" "),
redact_url_credentials(&String::from_utf8_lossy(&output.stdout)),
redact_url_credentials(&String::from_utf8_lossy(&output.stderr))
);
}
fn set_git_remote(dir: &Path, remote_url: &str) {
let output = Command::new("git")
.args(["remote", "add", "origin", remote_url])
.current_dir(dir)
.output()
.expect("Failed to run git remote add");
assert!(output.status.success(), "git remote add failed");
}
fn setup_test_repo(
version: &str,
token: &str,
owner: &str,
repo: &str,
branch: &str,
extra_knope_config: &str,
) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("Failed to create temp dir");
let path = dir.path();
assert_git(path, &["init", "-b", branch]);
assert_git(
path,
&["config", "user.email", "integration-test@knope.dev"],
);
assert_git(path, &["config", "user.name", "Knope Integration Test"]);
let remote_url = format!("https://x-access-token:{token}@github.com/{owner}/{repo}.git");
set_git_remote(path, &remote_url);
let knope_toml = format!(
r#"[package]
versioned_files = ["Cargo.toml"]
changelog = "CHANGELOG.md"
{extra_knope_config}
[[workflows]]
name = "release"
[[workflows.steps]]
type = "Release"
[github]
owner = "{owner}"
repo = "{repo}"
"#
);
std::fs::write(path.join("knope.toml"), knope_toml).expect("Failed to write knope.toml");
std::fs::write(
path.join("Cargo.toml"),
format!("[package]\nname = \"integration-test\"\nversion = \"{version}\"\n"),
)
.expect("Failed to write Cargo.toml");
std::fs::write(path.join("CHANGELOG.md"), "").expect("Failed to write CHANGELOG.md");
assert_git(path, &["add", "."]);
assert_git(path, &["commit", "-m", "chore: release"]);
dir
}
async fn delete_release(client: &Client, token: &str, owner: &str, repo: &str, release_id: u64) {
let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/{release_id}");
let _ = client
.delete(&url)
.header("Authorization", format!("token {token}"))
.header("Accept", "application/vnd.github+json")
.send()
.await;
}
async fn delete_tag(client: &Client, token: &str, owner: &str, repo: &str, tag: &str) {
let url = format!("https://api.github.com/repos/{owner}/{repo}/git/refs/tags/{tag}");
let _ = client
.delete(&url)
.header("Authorization", format!("token {token}"))
.header("Accept", "application/vnd.github+json")
.send()
.await;
}
async fn delete_branch(client: &Client, token: &str, owner: &str, repo: &str, branch: &str) {
let url = format!("https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}");
let _ = client
.delete(&url)
.header("Authorization", format!("token {token}"))
.header("Accept", "application/vnd.github+json")
.send()
.await;
}
async fn cleanup_release_by_tag(client: &Client, token: &str, owner: &str, repo: &str, tag: &str) {
let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}");
if let Ok(resp) = client
.get(&url)
.header("Authorization", format!("token {token}"))
.header("Accept", "application/vnd.github+json")
.send()
.await
{
if resp.status().is_success() {
if let Ok(release) = resp.json::<Release>().await {
delete_release(client, token, owner, repo, release.id).await;
}
}
}
delete_tag(client, token, owner, repo, tag).await;
}
#[tokio::test]
#[ignore = "requires external service credentials"]
async fn github_release_workflow() {
let (token, owner, repo) = github_env();
let client = http_client();
let branch = "integration-test-release";
let version = "0.1.0";
let expected_tag = format!("v{version}");
cleanup_release_by_tag(&client, &token, &owner, &repo, &expected_tag).await;
delete_branch(&client, &token, &owner, &repo, branch).await;
let dir = setup_test_repo(version, &token, &owner, &repo, branch, "");
let path = dir.path();
push_branch(path, branch);
let output = Command::new(env!("CARGO_BIN_EXE_knope"))
.current_dir(path)
.env("GITHUB_TOKEN", &token)
.args(["release"])
.output()
.expect("Failed to run knope");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
cleanup_release_by_tag(&client, &token, &owner, &repo, &expected_tag).await;
delete_branch(&client, &token, &owner, &repo, branch).await;
panic!("knope release failed:\nstdout: {stdout}\nstderr: {stderr}");
}
let mut release_opt = None;
for _ in 0..5 {
let resp = client
.get(format!(
"https://api.github.com/repos/{owner}/{repo}/releases/tags/{expected_tag}"
))
.header("Authorization", format!("token {token}"))
.header("Accept", "application/vnd.github+json")
.send()
.await
.expect("Failed to fetch release");
if resp.status().is_success() {
release_opt = Some(
resp.json::<Release>()
.await
.expect("Failed to deserialize release"),
);
break;
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
let Some(release) = release_opt else {
cleanup_release_by_tag(&client, &token, &owner, &repo, &expected_tag).await;
delete_branch(&client, &token, &owner, &repo, branch).await;
panic!("Release {expected_tag} should exist on GitHub after retries");
};
assert_eq!(release.tag_name, expected_tag);
delete_release(&client, &token, &owner, &repo, release.id).await;
delete_tag(&client, &token, &owner, &repo, &expected_tag).await;
delete_branch(&client, &token, &owner, &repo, branch).await;
}
#[tokio::test]
#[ignore = "requires external service credentials"]
async fn github_release_with_assets() {
let (token, owner, repo) = github_env();
let client = http_client();
let branch = "integration-test-assets";
let version = "0.2.0";
let expected_tag = format!("v{version}");
cleanup_release_by_tag(&client, &token, &owner, &repo, &expected_tag).await;
delete_branch(&client, &token, &owner, &repo, branch).await;
let asset_config = "\n[[package.assets]]\npath = \"dist/test-asset.txt\"\n";
let dir = setup_test_repo(version, &token, &owner, &repo, branch, asset_config);
let path = dir.path();
std::fs::create_dir_all(path.join("dist")).expect("Failed to create dist dir");
std::fs::write(
path.join("dist/test-asset.txt"),
"Hello from knope integration tests!",
)
.expect("Failed to write asset");
push_branch(path, branch);
let output = Command::new(env!("CARGO_BIN_EXE_knope"))
.current_dir(path)
.env("GITHUB_TOKEN", &token)
.args(["release"])
.output()
.expect("Failed to run knope");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
cleanup_release_by_tag(&client, &token, &owner, &repo, &expected_tag).await;
delete_branch(&client, &token, &owner, &repo, branch).await;
panic!("knope release with assets failed:\nstdout: {stdout}\nstderr: {stderr}");
}
let mut release_opt = None;
for _ in 0..5 {
let resp = client
.get(format!(
"https://api.github.com/repos/{owner}/{repo}/releases/tags/{expected_tag}"
))
.header("Authorization", format!("token {token}"))
.header("Accept", "application/vnd.github+json")
.send()
.await
.expect("Failed to fetch release");
if resp.status().is_success() {
release_opt = Some(
resp.json::<Release>()
.await
.expect("Failed to deserialize release"),
);
break;
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
let Some(release) = release_opt else {
cleanup_release_by_tag(&client, &token, &owner, &repo, &expected_tag).await;
delete_branch(&client, &token, &owner, &repo, branch).await;
panic!("Release {expected_tag} should exist on GitHub after retries");
};
let assets_url = format!(
"https://api.github.com/repos/{owner}/{repo}/releases/{}/assets",
release.id
);
let resp = client
.get(&assets_url)
.header("Authorization", format!("token {token}"))
.header("Accept", "application/vnd.github+json")
.send()
.await
.expect("Failed to fetch release assets");
let assets: Vec<Asset> = resp.json().await.expect("Failed to deserialize assets");
delete_release(&client, &token, &owner, &repo, release.id).await;
delete_tag(&client, &token, &owner, &repo, &expected_tag).await;
delete_branch(&client, &token, &owner, &repo, branch).await;
assert!(
assets.iter().any(|a| a.name == "test-asset.txt"),
"Uploaded asset should appear in the release assets list"
);
}
#[tokio::test]
#[ignore = "requires external service credentials"]
async fn github_error_bad_token() {
let (_token, owner, repo) = github_env();
let dir = tempfile::tempdir().expect("Failed to create temp dir");
let path = dir.path();
assert_git(path, &["init"]);
assert_git(
path,
&["config", "user.email", "integration-test@knope.dev"],
);
assert_git(path, &["config", "user.name", "Knope Integration Test"]);
let knope_toml = format!(
r#"[package]
versioned_files = ["Cargo.toml"]
changelog = "CHANGELOG.md"
[[workflows]]
name = "release"
[[workflows.steps]]
type = "Release"
[github]
owner = "{owner}"
repo = "{repo}"
"#
);
std::fs::write(path.join("knope.toml"), knope_toml).expect("Failed to write knope.toml");
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"integration-test\"\nversion = \"0.1.0\"\n",
)
.expect("Failed to write Cargo.toml");
std::fs::write(path.join("CHANGELOG.md"), "").expect("Failed to write CHANGELOG.md");
assert_git(path, &["add", "."]);
assert_git(path, &["commit", "-m", "chore: release"]);
let output = Command::new(env!("CARGO_BIN_EXE_knope"))
.current_dir(path)
.env("GITHUB_TOKEN", "bad-token-value")
.args(["release"])
.output()
.expect("Failed to run knope");
assert!(
!output.status.success(),
"knope release should fail with a bad token"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.is_empty(),
"Expected error output when using a bad token"
);
}
#[tokio::test]
#[ignore = "requires external service credentials"]
async fn pull_request_creation_and_info_lookup() {
let base_branch = "integration-test-prs-base";
let extra_knope_config = format!(
r#"
[release_notes]
change_templates = [
"* $summary by @$pr_author_login in #$pr_number",
"* $summary by @$pr_author_login",
"* $summary",
]
[[workflows]]
name = "pr"
[[workflows.steps]]
type = "CreatePullRequest"
base = "{base_branch}"
title = {{template = "Integration test PR"}}
body = {{template = "This is an integration test PR for knope"}}
[[workflows]]
name = "prepare"
[[workflows.steps]]
type = "PrepareRelease"
"#
);
let (token, owner, repo) = github_env();
ensure_gh_installed();
let client = http_client();
let branch = "integration-test-prs-head";
delete_branch(&client, &token, &owner, &repo, branch).await;
delete_branch(&client, &token, &owner, &repo, base_branch).await;
let dir = setup_test_repo(
"0.3.0",
&token,
&owner,
&repo,
base_branch,
&extra_knope_config,
);
let path = dir.path();
push_branch(path, base_branch);
assert_git(path, &["checkout", "-b", branch]);
create_dir_all(path.join(".changeset"))
.await
.expect("Failed to create .changeset dir");
write(
path.join(".changeset/test.md"),
r"---
default: minor
---
# Test changeset",
)
.unwrap();
assert_git(path, &["add", ".changeset/test.md"]);
assert_git(path, &["commit", "-m", "feat: test commit"]);
push_branch(path, branch);
let output = Command::new(env!("CARGO_BIN_EXE_knope"))
.current_dir(path)
.env("GITHUB_TOKEN", &token)
.args(["pr"])
.output()
.expect("Failed to run knope");
assert!(
output.status.success(),
"knope pr command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let pr = get_pr_info(&token, &owner, &repo);
merge_pr(&token, &owner, &repo, pr.number);
assert_git(path, &["checkout", branch]);
assert_git(path, &["pull", "--rebase"]);
wait_for_github_to_be_ready(&token, owner, repo, client, &pr).await;
let output = Command::new(env!("CARGO_BIN_EXE_knope"))
.current_dir(path)
.env("RUST_LOG", "knope=debug")
.env("GITHUB_TOKEN", token)
.args(["prepare"])
.output()
.expect("Failed to run knope");
assert!(
output.status.success(),
"knope prepare command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let changelog = std::fs::read_to_string(path.join("CHANGELOG.md")).unwrap();
assert!(
changelog.contains(pr.number.to_string().as_str()),
"PR number should be included in the changelog, got {changelog}.\n Knope output: {}",
String::from_utf8_lossy(&output.stdout)
);
assert!(
changelog.contains(pr.author.login.as_str()),
"PR author should be included in the changelog, got {changelog}.\n Knope output: {}",
String::from_utf8_lossy(&output.stdout)
);
}
async fn wait_for_github_to_be_ready(
token: &String,
owner: String,
repo: String,
client: Client,
pr: &PrInfo,
) {
for attempt in 0..10u32 {
sleep(Duration::from_secs(1)).await;
let resp = client
.get(format!(
"https://api.github.com/repos/{owner}/{repo}/pulls/{}",
pr.number
))
.header("Authorization", format!("token {token}"))
.header("Accept", "application/vnd.github+json")
.send()
.await
.expect("Failed to fetch PR state");
let pr_data: serde_json::Value = resp.json().await.expect("Failed to deserialize PR");
if pr_data
.get("merged")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
break;
}
assert!(
attempt < 9,
"PR {} did not show as merged after retries",
pr.number
);
}
}
fn merge_pr(token: &str, owner: &str, repo: &str, pr_number: u64) {
let output = Command::new("gh")
.arg("pr")
.arg("merge")
.arg("--repo")
.arg(format!("{owner}/{repo}"))
.arg("--squash")
.arg(pr_number.to_string())
.env("GITHUB_TOKEN", token)
.output()
.expect("Failed to run gh");
assert!(
output.status.success(),
"gh pr merge failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn get_pr_info(token: &str, owner: &str, repo: &str) -> PrInfo {
let output = Command::new("gh")
.arg("pr")
.arg("list")
.arg("--repo")
.arg(format!("{owner}/{repo}"))
.arg("--json")
.arg("number,author")
.arg("--head")
.arg("integration-test-prs-head")
.env("GITHUB_TOKEN", token)
.output()
.expect("Failed to run gh");
serde_json::from_slice::<Vec<PrInfo>>(&output.stdout)
.expect("Failed to parse gh output")
.remove(0)
}
#[derive(serde::Deserialize)]
struct PrInfo {
number: u64,
author: PrAuthor,
}
#[derive(serde::Deserialize)]
struct PrAuthor {
login: String,
}