pakker/src/platform/github.rs
NotAShelf 7ee9ee1159
various: shared HTTP client with connection pooling
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id13c17e9352da970a289f4e3ad909c5b6a6a6964
2026-02-19 00:22:48 +03:00

580 lines
16 KiB
Rust

use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use regex::Regex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use super::traits::PlatformClient;
use crate::{
error::{PakkerError, Result},
model::{Project, ProjectFile, ProjectSide, ProjectType, ReleaseType},
utils::generate_pakku_id,
};
const GITHUB_API_BASE: &str = "https://api.github.com";
pub struct GitHubPlatform {
client: Client,
token: Option<String>,
}
impl GitHubPlatform {
pub fn with_client(client: Arc<Client>, token: Option<String>) -> Self {
Self {
client: (*client).clone(),
token,
}
}
fn get_headers(&self) -> Result<reqwest::header::HeaderMap> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static("Pakker"),
);
if let Some(token) = &self.token {
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|_| {
PakkerError::ConfigError("Invalid GitHub token".to_string())
})?,
);
}
Ok(headers)
}
fn parse_repo_identifier(identifier: &str) -> Result<(String, String)> {
// Expected formats:
// - "owner/repo"
// - "github:owner/repo"
// - "https://github.com/owner/repo"
let identifier = identifier
.trim_start_matches("github:")
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_end_matches(".git");
let parts: Vec<&str> = identifier.split('/').collect();
if parts.len() >= 2 {
Ok((parts[0].to_string(), parts[1].to_string()))
} else {
Err(PakkerError::InvalidInput(format!(
"Invalid GitHub repository identifier: {identifier}"
)))
}
}
fn convert_release(
&self,
owner: &str,
repo: &str,
release: GitHubRelease,
) -> Project {
let pakku_id = generate_pakku_id();
let mut project =
Project::new(pakku_id, ProjectType::Mod, ProjectSide::Both);
let repo_full = format!("{owner}/{repo}");
project.add_platform(
"github".to_string(),
repo_full.clone(),
repo_full,
release.name.unwrap_or_else(|| repo.to_string()),
);
project
}
}
// Helper functions for extracting metadata from GitHub releases
fn extract_mc_versions(tag: &str, asset_name: &str) -> Vec<String> {
let re = Regex::new(r"(?:^|[^\d.])(\d+\.\d+(?:\.\d+)?)(?:[^\d]|$)").unwrap();
let mut versions = Vec::new();
log::debug!("Extracting MC versions from tag='{tag}', asset='{asset_name}'");
for text in &[tag, asset_name] {
for cap in re.captures_iter(text) {
if let Some(version) = cap.get(1) {
let v = version.as_str().to_string();
if !versions.contains(&v) {
log::debug!(" Found MC version: {v}");
versions.push(v);
}
}
}
}
log::debug!("Extracted MC versions: {versions:?}");
versions
}
fn extract_loaders(tag: &str, asset_name: &str) -> Vec<String> {
let mut loaders = Vec::new();
let text = format!("{} {}", tag.to_lowercase(), asset_name.to_lowercase());
log::debug!("Extracting loaders from: '{text}'");
if text.contains("fabric") {
log::debug!(" Found loader: fabric");
loaders.push("fabric".to_string());
}
if text.contains("forge") && !text.contains("neoforge") {
log::debug!(" Found loader: forge");
loaders.push("forge".to_string());
}
if text.contains("neoforge") {
log::debug!(" Found loader: neoforge");
loaders.push("neoforge".to_string());
}
if text.contains("quilt") {
log::debug!(" Found loader: quilt");
loaders.push("quilt".to_string());
}
log::debug!("Extracted loaders: {loaders:?}");
loaders
}
fn detect_project_type(asset_name: &str, repo_name: &str) -> ProjectType {
let name_lower = asset_name.to_lowercase();
let repo_lower = repo_name.to_lowercase();
// Check for resourcepack indicators
if name_lower.contains("resourcepack")
|| name_lower.contains("resource-pack")
|| name_lower.contains("texture")
|| repo_lower.contains("resourcepack")
|| repo_lower.contains("texture")
{
return ProjectType::ResourcePack;
}
// Check for datapack indicators
if name_lower.contains("datapack")
|| name_lower.contains("data-pack")
|| repo_lower.contains("datapack")
{
return ProjectType::DataPack;
}
// Check for shader indicators
if name_lower.contains("shader") || repo_lower.contains("shader") {
return ProjectType::Shader;
}
// Check for world/save indicators
if name_lower.contains("world")
|| name_lower.contains("save")
|| repo_lower.contains("world")
{
return ProjectType::World;
}
// Default to mod for .jar files
ProjectType::Mod
}
impl GitHubPlatform {
fn convert_asset(
&self,
asset: GitHubAsset,
release: &GitHubRelease,
repo_id: &str,
repo_name: &str,
) -> ProjectFile {
let hashes = HashMap::new();
// Extract MC versions and loaders from tag and asset name
let mc_versions = extract_mc_versions(&release.tag_name, &asset.name);
let loaders = extract_loaders(&release.tag_name, &asset.name);
// Detect project type from asset name and repo
let file_type = match detect_project_type(&asset.name, repo_name) {
ProjectType::Mod => "mod",
ProjectType::ResourcePack => "resourcepack",
ProjectType::DataPack => "datapack",
ProjectType::Shader => "shader",
ProjectType::World => "world",
};
ProjectFile {
file_type: file_type.to_string(),
file_name: asset.name.clone(),
mc_versions,
loaders,
release_type: if release.prerelease {
ReleaseType::Beta
} else {
ReleaseType::Release
},
url: asset.browser_download_url.clone(),
id: asset.id.to_string(),
parent_id: repo_id.to_string(),
hashes,
required_dependencies: vec![],
size: asset.size,
date_published: release.published_at.clone().unwrap_or_default(),
}
}
async fn get_latest_release(
&self,
owner: &str,
repo: &str,
) -> Result<GitHubRelease> {
let url = format!("{GITHUB_API_BASE}/repos/{owner}/{repo}/releases/latest");
let response = self
.client
.get(&url)
.headers(self.get_headers()?)
.send()
.await?;
if !response.status().is_success() {
return Err(PakkerError::ProjectNotFound(format!("{owner}/{repo}")));
}
let release: GitHubRelease = response.json().await?;
Ok(release)
}
async fn get_all_releases(
&self,
owner: &str,
repo: &str,
) -> Result<Vec<GitHubRelease>> {
let url = format!("{GITHUB_API_BASE}/repos/{owner}/{repo}/releases");
let response = self
.client
.get(&url)
.headers(self.get_headers()?)
.send()
.await?;
if !response.status().is_success() {
return Err(PakkerError::ProjectNotFound(format!("{owner}/{repo}")));
}
let releases: Vec<GitHubRelease> = response.json().await?;
Ok(releases)
}
}
#[async_trait]
impl PlatformClient for GitHubPlatform {
async fn request_project(
&self,
identifier: &str,
_mc_versions: &[String],
_loaders: &[String],
) -> Result<Project> {
let (owner, repo) = Self::parse_repo_identifier(identifier)?;
let release = self.get_latest_release(&owner, &repo).await?;
Ok(self.convert_release(&owner, &repo, release))
}
async fn request_project_files(
&self,
project_id: &str,
_mc_versions: &[String],
_loaders: &[String],
) -> Result<Vec<ProjectFile>> {
let (owner, repo) = Self::parse_repo_identifier(project_id)?;
let releases = self.get_all_releases(&owner, &repo).await?;
let mut files = Vec::new();
for release in releases {
for asset in &release.assets {
// Filter for .jar files (mods) or .zip files (modpacks)
if asset.name.ends_with(".jar") || asset.name.ends_with(".zip") {
let file =
self.convert_asset(asset.clone(), &release, project_id, &repo);
files.push(file);
}
}
}
Ok(files)
}
async fn request_project_with_files(
&self,
identifier: &str,
_mc_versions: &[String],
_loaders: &[String],
) -> Result<Project> {
let mut project = self
.request_project(identifier, _mc_versions, _loaders)
.await?;
let project_id = project
.get_platform_id("github")
.ok_or_else(|| {
PakkerError::InternalError("Missing github ID".to_string())
})?
.clone();
let files = self
.request_project_files(&project_id, _mc_versions, _loaders)
.await?;
project.files = files;
Ok(project)
}
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>> {
log::debug!("GitHub lookup_by_hash: searching for hash={hash}");
// GitHub Code Search API: search for files containing the hash
// Note: This is rate-limited (10 req/min without auth, 30 req/min with
// auth)
let url = format!("{GITHUB_API_BASE}/search/code?q={hash}+in:file");
log::debug!("GitHub search URL: {url}");
let response = match self
.client
.get(&url)
.headers(self.get_headers()?)
.send()
.await
{
Ok(resp) => {
log::debug!("GitHub search response status: {}", resp.status());
resp
},
Err(e) => {
log::warn!("GitHub hash lookup failed: {e}");
return Ok(None);
},
};
// Handle rate limiting gracefully
if response.status().as_u16() == 403 {
log::warn!("GitHub API rate limit exceeded for hash lookup");
return Ok(None);
}
if !response.status().is_success() {
log::debug!(
"GitHub search returned non-success status: {}",
response.status()
);
return Ok(None);
}
let search_result: GitHubCodeSearchResult = match response.json().await {
Ok(result) => result,
Err(e) => {
log::warn!("Failed to parse GitHub search result: {e}");
return Ok(None);
},
};
log::debug!("GitHub search found {} items", search_result.items.len());
// If we found matches, try to extract repo info from first result
if let Some(item) = search_result.items.first() {
let repo_full = item.repository.full_name.clone();
log::info!("GitHub hash lookup found match in repo: {repo_full}");
// Try to get the latest release for this repo
match self.request_project(&repo_full, &[], &[]).await {
Ok(project) => {
log::info!("GitHub hash lookup succeeded for {repo_full}");
Ok(Some(project))
},
Err(e) => {
log::warn!("Failed to fetch project for {repo_full}: {e}");
Ok(None)
},
}
} else {
log::debug!("GitHub hash lookup found no matches");
Ok(None)
}
}
}
// GitHub API models
#[derive(Debug, Clone, Deserialize, Serialize)]
struct GitHubRelease {
id: u64,
tag_name: String,
name: Option<String>,
prerelease: bool,
published_at: Option<String>,
assets: Vec<GitHubAsset>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct GitHubAsset {
id: u64,
name: String,
browser_download_url: String,
size: u64,
}
#[derive(Debug, Deserialize)]
struct GitHubCodeSearchResult {
items: Vec<GitHubCodeSearchItem>,
}
#[derive(Debug, Deserialize)]
struct GitHubCodeSearchItem {
repository: GitHubRepository,
}
#[derive(Debug, Deserialize)]
struct GitHubRepository {
full_name: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_repo_identifier() {
let cases = vec![
("owner/repo", ("owner", "repo")),
("github:owner/repo", ("owner", "repo")),
("https://github.com/owner/repo", ("owner", "repo")),
("https://github.com/owner/repo.git", ("owner", "repo")),
];
for (input, (expected_owner, expected_repo)) in cases {
let (owner, repo) = GitHubPlatform::parse_repo_identifier(input).unwrap();
assert_eq!(owner, expected_owner);
assert_eq!(repo, expected_repo);
}
}
#[test]
fn test_parse_repo_identifier_invalid() {
let result = GitHubPlatform::parse_repo_identifier("invalid");
assert!(result.is_err());
}
#[test]
fn test_extract_mc_versions() {
let cases = vec![
("1.20.4-forge-1.0.0", "", vec!["1.20.4", "1.0.0"]),
("fabric-1.21-1.0.0", "", vec!["1.21"]),
("mc1.20.4", "", vec!["1.20.4"]),
("1.20.1-1.20.2", "", vec!["1.20.1"]),
("mymod-1.0.0", "", vec!["1.0.0"]),
("mc1.20.4-v1.0.0", "", vec!["1.20.4", "1.0.0"]),
("v1.0.0", "mymod-1.20.4.jar", vec!["1.0.0", "1.20.4"]),
("1.20.1-47.1.0", "", vec!["1.20.1"]),
("v0.5.1+1.20.1", "", vec!["0.5.1"]),
("1.20.4-1.0.0+fabric", "", vec!["1.20.4"]),
("mc1.19.2-v2.1.3", "", vec!["1.19.2", "2.1.3"]),
("1.20-Snapshot", "", vec!["1.20"]),
("v3.0.0-beta.2+mc1.20.4", "", vec!["3.0.0", "1.20.4"]),
("1.16.5-1.0", "", vec!["1.16.5"]),
("forge-1.20.1-47.2.0", "", vec!["1.20.1"]),
("1.20.2-neoforge-20.2.59", "", vec!["1.20.2", "20.2.59"]),
("release-1.20.1", "", vec!["1.20.1"]),
("1.19.4_v2.5.0", "", vec!["1.19.4", "2.5.0"]),
("MC1.18.2-v1.0.0", "", vec!["1.18.2", "1.0.0"]),
("1.20.1-forge-v1.2.3", "", vec!["1.20.1", "1.2.3"]),
("Minecraft_1.19.2-v0.8.1", "", vec!["1.19.2", "0.8.1"]),
("build-1.20.4-2.1.0", "", vec!["1.20.4"]),
("1.20.x-1.5.0", "", vec!["1.20", "1.5.0"]),
("1.12.2-14.23.5.2859", "", vec!["1.12.2"]),
];
for (tag, asset, expected) in cases {
let result = extract_mc_versions(tag, asset);
assert_eq!(
result, expected,
"Failed for tag: {}, asset: {}",
tag, asset
);
}
}
#[test]
fn test_extract_loaders() {
let cases = vec![
("1.20.4-forge-1.0.0", "", vec!["forge"]),
("fabric-1.21-1.0.0", "", vec!["fabric"]),
("1.20.1-neoforge", "", vec!["neoforge"]),
("quilt-1.20.4", "", vec!["quilt"]),
("mymod-1.0.0", "", vec![]),
("1.20.4-forge-fabric", "", vec!["fabric", "forge"]), /* Alphabetical
* order */
("v1.0.0", "mymod-fabric-1.20.4.jar", vec!["fabric"]),
// Real-world patterns
("1.20.1-forge-47.1.0", "", vec!["forge"]),
("fabric-api-0.92.0+1.20.4", "", vec!["fabric"]),
("1.19.2-neoforge-20.2.59", "", vec!["neoforge"]),
("quilt-loader-0.23.0", "", vec!["quilt"]),
("1.20.4-Fabric-1.0.0", "", vec!["fabric"]), // Capitalized
("forge-1.20.1", "", vec!["forge"]),
("v1.0.0-fabric", "", vec!["fabric"]),
("1.18.2-forge+fabric", "", vec!["fabric", "forge"]), // Both loaders
("NeoForge-1.20.2", "", vec!["neoforge"]), /* Capitalized
* NeoForge */
("1.12.2-forge-14.23.5.2859", "", vec!["forge"]), // Old format
];
for (tag, asset, expected) in cases {
let result = extract_loaders(tag, asset);
assert_eq!(
result, expected,
"Failed for tag: {}, asset: {}",
tag, asset
);
}
}
#[test]
fn test_detect_project_type() {
let cases = vec![
("mymod.jar", "mymod", crate::model::ProjectType::Mod),
(
"texture-pack.zip",
"texture",
crate::model::ProjectType::ResourcePack,
),
(
"resourcepack.zip",
"resources",
crate::model::ProjectType::ResourcePack,
),
(
"datapack.zip",
"data-stuff",
crate::model::ProjectType::DataPack,
),
(
"shader.zip",
"awesome-shaders",
crate::model::ProjectType::Shader,
),
("world.zip", "my-world", crate::model::ProjectType::World),
("save.zip", "survival", crate::model::ProjectType::World),
("unknown.zip", "stuff", crate::model::ProjectType::Mod),
];
for (filename, repo_name, expected) in cases {
let result = detect_project_type(filename, repo_name);
assert_eq!(
result, expected,
"Failed for filename: {}, repo: {}",
filename, repo_name
);
}
}
}