Split project into crates, migrate to maud

This commit is contained in:
Luke Street
2025-04-21 18:29:36 -06:00
parent b92deabb7a
commit cb2f04fb75
50 changed files with 2707 additions and 2495 deletions
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT owner, repo, name, short_name, default_category, default_version, platform, workflow_id\n FROM projects\n WHERE id = ?\n ",
"query": "\n SELECT owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments\n FROM projects\n WHERE id = ?\n ",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"name": "workflow_id",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "enable_pr_comments",
"ordinal": 8,
"type_info": "Bool"
}
],
"parameters": {
@@ -55,8 +60,9 @@
true,
true,
true,
true
true,
false
]
},
"hash": "269859ed391626ddbc2af37381a13c36976f48e6a89aaaa03da8ea66baccddff"
"hash": "23a6be61c86478402eb00bb9fd64642188eec62eec0846a4a25a8110459997f1"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n projects.id AS \"project_id!\",\n owner AS \"owner!\",\n repo AS \"repo!\",\n name,\n short_name,\n default_category,\n default_version,\n platform,\n workflow_id,\n git_commit,\n git_commit_message,\n MAX(timestamp) AS \"timestamp: time::OffsetDateTime\",\n JSON_GROUP_ARRAY(version ORDER BY version)\n FILTER (WHERE version IS NOT NULL) AS versions\n FROM projects LEFT JOIN reports ON (\n reports.project_id = projects.id\n AND reports.timestamp = (\n SELECT MAX(timestamp)\n FROM reports\n WHERE project_id = projects.id\n )\n )\n GROUP BY projects.id\n ORDER BY MAX(timestamp) DESC\n ",
"query": "\n SELECT\n projects.id AS \"project_id!\",\n owner AS \"owner!\",\n repo AS \"repo!\",\n name,\n short_name,\n default_category,\n default_version,\n platform,\n workflow_id,\n enable_pr_comments AS \"enable_pr_comments!\",\n git_commit,\n git_commit_message,\n MAX(timestamp) AS \"timestamp: time::OffsetDateTime\",\n JSON_GROUP_ARRAY(version ORDER BY version)\n FILTER (WHERE version IS NOT NULL) AS versions\n FROM projects LEFT JOIN reports ON (\n reports.project_id = projects.id\n AND reports.timestamp = (\n SELECT MAX(timestamp)\n FROM reports\n WHERE project_id = projects.id\n )\n )\n GROUP BY projects.id\n ORDER BY MAX(timestamp) DESC\n ",
"describe": {
"columns": [
{
@@ -49,24 +49,29 @@
"type_info": "Text"
},
{
"name": "git_commit",
"name": "enable_pr_comments!",
"ordinal": 9,
"type_info": "Text"
"type_info": "Bool"
},
{
"name": "git_commit_message",
"name": "git_commit",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "timestamp: time::OffsetDateTime",
"name": "git_commit_message",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "versions",
"name": "timestamp: time::OffsetDateTime",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "versions",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
@@ -85,8 +90,9 @@
true,
true,
true,
true,
true
]
},
"hash": "b492f0562501ae39110cf2df7a545cd0c676b438b91d63b915988c17d5bb29b6"
"hash": "912ad9086ec8b4caa4acf3e6642cfce34ebcff88a5fe321b31a56af31e8eda83"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id AS \"id!\", owner, repo, name, short_name, default_category, default_version, platform, workflow_id\n FROM projects\n WHERE owner = ? COLLATE NOCASE AND repo = ? COLLATE NOCASE\n ",
"query": "\n SELECT id AS \"id!\", owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments\n FROM projects\n WHERE owner = ? COLLATE NOCASE AND repo = ? COLLATE NOCASE\n ",
"describe": {
"columns": [
{
@@ -47,6 +47,11 @@
"name": "workflow_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "enable_pr_comments",
"ordinal": 9,
"type_info": "Bool"
}
],
"parameters": {
@@ -61,8 +66,9 @@
true,
true,
true,
true
true,
false
]
},
"hash": "60a01415ef25ea6a2fc7f3287e0e6972404e351401f8aeaedbc74b4c934c777c"
"hash": "ef595aa7d32d7d21d9721a794295a7d10d0edc60d25c1809bb94935e468873fd"
}
Generated
+992 -870
View File
File diff suppressed because it is too large Load Diff
+23 -42
View File
@@ -1,56 +1,37 @@
[package]
name = "decompal-rs"
version = "0.1.0"
edition = "2021"
[workspace]
resolver = "3"
members = [
"crates/auth",
"crates/core",
"crates/db",
"crates/github",
"crates/images",
"crates/scripts",
"crates/web",
]
[dependencies]
[workspace.package]
version = "0.1.0"
edition = "2024"
[workspace.dependencies]
anyhow = "1.0"
ariadne = { version = "0.4", features = ["auto-color"] }
axum = { version = "0.8", features = ["macros"] }
badge-maker = "0.3"
blake3 = "1.5"
bytes = "1.7"
futures-util = "0.3.31"
grass = "0.13"
hex = "0.4"
human-size = "0.4"
image = "0.25"
itertools = "0.14"
lightningcss = "1.0.0-alpha.65"
mime = "0.3"
minijinja = { version = "2.9", features = ["loader", "json"] }
minijinja-autoreload = "2.9"
moka = { version = "0.12", features = ["future"] }
objdiff-core = { version = "2.5", features = ["bindings"] }
#objdiff-core = { path = "../objdiff/objdiff-core", features = ["bindings"] }
octocrab = { version = "0.44", features = ["stream"] }
oxc = { version = "0.31", features = ["codegen", "minifier", "transformer", "semantic"] }
palette = "0.7"
prost = "0.13"
regex = "1.11"
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "zstd", "charset", "http2", "rustls-tls-native-roots"] }
resvg = "0.45"
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "zstd", "http2", "rustls-tls-native-roots"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono"] }
streemap = "0.1"
timeago = { version = "0.4.2", default-features = false, features = ["chrono"] }
time = { version = "0.3", features = ["serde", "macros"] }
tokio = { version = "1.40", features = ["full"] }
tokio-cron-scheduler = "0.13"
tower = { version = "0.5", features = ["full"] }
tower-http = { version = "0.6", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = { version = "2.5", features = ["serde"] }
zip = { version = "2.6", default-features = false, features = ["flate2", "deflate-flate2"] }
zstd = "0.13"
jsonwebtoken = "9.3"
hmac = "0.12"
sha2 = "0.10"
async-openai = "0.28"
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] }
time = { version = "0.3", features = ["local-offset"] }
rand = "0.9"
base64 = "0.22"
tracing = "0.1"
url = { version = "2.5", features = ["serde"] }
[profile.dev.package.sqlx-macros]
opt-level = 3
View File

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

+18
View File
@@ -0,0 +1,18 @@
[package]
name = "decomp-dev-auth"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
anyhow.workspace = true
axum.workspace = true
base64 = "0.22"
rand = "0.9"
decomp-dev-core = { path = "../core" }
octocrab.workspace = true
time.workspace = true
serde.workspace = true
tower-sessions.workspace = true
tracing.workspace = true
url.workspace = true
+23 -23
View File
@@ -1,17 +1,16 @@
use anyhow::{anyhow, bail, Context};
use anyhow::{Context, anyhow, bail};
use axum::{
extract::{FromRef, FromRequestParts, OptionalFromRequestParts, Query, State},
http::{header::ACCEPT, request::Parts, StatusCode},
http::{StatusCode, header::ACCEPT, request::Parts},
response::{IntoResponse, Redirect, Response},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use octocrab::{models::Author, Octocrab};
use rand::{rngs::OsRng, TryRngCore};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use decomp_dev_core::{AppError, config::GitHubConfig};
use octocrab::{Octocrab, models::Author};
use rand::{TryRngCore, rngs::OsRng};
use time::{Duration, UtcDateTime};
use tower_sessions::Session;
use crate::{config::GitHubConfig, handlers::AppError, AppState};
const GITHUB_OAUTH_STATE: &str = "github_oauth_state";
const CURRENT_USER: &str = "current_user";
@@ -37,21 +36,23 @@ impl From<StoredOAuth> for octocrab::auth::OAuth {
}
}
pub type Profile = Author;
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct CurrentUser {
pub oauth: StoredOAuth,
pub profile: Author,
pub profile: Profile,
}
pub async fn login(
session: Session,
State(state): State<AppState>,
State(config): State<GitHubConfig>,
current_user: Option<CurrentUser>,
) -> Result<Response, AppError> {
if current_user.is_some() {
return Ok(Redirect::to("/").into_response());
}
let Some(config) = &state.config.github.oauth else {
let Some(config) = &config.oauth else {
tracing::warn!("No GitHub OAuth config found");
return Ok((StatusCode::INTERNAL_SERVER_ERROR, "No GitHub OAuth config").into_response());
};
@@ -134,7 +135,7 @@ struct RefreshAccessToken<'a> {
pub async fn oauth(
session: Session,
Query(OAuthQuery { code, state: oauth_state }): Query<OAuthQuery>,
State(state): State<AppState>,
State(config): State<GitHubConfig>,
) -> Result<Response, AppError> {
let existing_state = session.get::<String>(GITHUB_OAUTH_STATE).await?;
let Some(existing_state) = existing_state else {
@@ -147,7 +148,7 @@ pub async fn oauth(
}
session.remove_value(GITHUB_OAUTH_STATE).await?;
let current_user = fetch_access_token(&state.config.github, &code).await?;
let current_user = fetch_access_token(&config, &code).await?;
session.insert(CURRENT_USER, current_user).await?;
Ok(Redirect::to("/").into_response())
@@ -214,7 +215,7 @@ async fn refresh_access_token(
impl<S> FromRequestParts<S> for CurrentUser
where
AppState: FromRef<S>,
GitHubConfig: FromRef<S>,
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
@@ -228,7 +229,7 @@ where
impl<S> OptionalFromRequestParts<S> for CurrentUser
where
AppState: FromRef<S>,
GitHubConfig: FromRef<S>,
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
@@ -238,7 +239,7 @@ where
state: &S,
) -> Result<Option<Self>, Self::Rejection> {
let session = Session::from_request_parts(parts, state).await?;
let app_state = AppState::from_ref(state);
let config = GitHubConfig::from_ref(state);
let Some(user) = session.get::<CurrentUser>(CURRENT_USER).await.ok().flatten() else {
return Ok(None);
};
@@ -255,14 +256,13 @@ where
return Ok(None);
}
}
let current_user =
match refresh_access_token(&app_state.config.github, refresh_token).await {
Ok(current_user) => current_user,
Err(e) => {
tracing::error!("Failed to refresh access token: {:?}", e);
return Ok(None);
}
};
let current_user = match refresh_access_token(&config, refresh_token).await {
Ok(current_user) => current_user,
Err(e) => {
tracing::error!("Failed to refresh access token: {:?}", e);
return Ok(None);
}
};
if let Err(e) = session.insert(CURRENT_USER, current_user.clone()).await {
tracing::error!("Failed to insert user into session: {}", e);
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "decomp-dev-core"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
anyhow.workspace = true
objdiff-core.workspace = true
serde.workspace = true
time.workspace = true
url.workspace = true
axum.workspace = true
mime.workspace = true
tracing.workspace = true
+83
View File
@@ -0,0 +1,83 @@
pub mod config;
pub mod models;
pub mod util;
use std::{convert::Infallible, net::SocketAddr};
use axum::{
Extension,
extract::{ConnectInfo, FromRequestParts, OriginalUri},
http::{StatusCode, Uri, header, request::Parts},
response::{IntoResponse, Response},
};
pub enum AppError {
Status(StatusCode),
Internal(anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
Self::Status(status) if status == StatusCode::NOT_FOUND => {
(status, "Not found!!").into_response()
}
Self::Status(status) => status.into_response(),
Self::Internal(err) => {
tracing::error!("{:?}", err);
(StatusCode::INTERNAL_SERVER_ERROR, format!("Something went wrong: {}", err))
.into_response()
}
}
}
}
impl<E: Into<anyhow::Error>> From<E> for AppError {
fn from(err: E) -> Self { Self::Internal(err.into()) }
}
/// Extractor for the full URI of the request, including the scheme and authority.
/// Uses the `x-forwarded-proto` and `x-forwarded-host` headers if present.
pub struct FullUri(pub Uri);
impl<S> FromRequestParts<S> for FullUri
where S: Send + Sync
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let uri = Extension::<OriginalUri>::from_request_parts(parts, state)
.await
.map_or_else(|_| parts.uri.clone(), |Extension(OriginalUri(uri))| uri);
let mut builder = Uri::builder();
if let Some(scheme) =
parts.headers.get("x-forwarded-proto").and_then(|value| value.to_str().ok())
{
builder = builder.scheme(scheme);
} else if let Some(scheme) = uri.scheme().cloned() {
builder = builder.scheme(scheme);
} else {
// TODO: native https?
builder = builder.scheme("http");
}
if let Some(host) =
parts.headers.get("x-forwarded-host").and_then(|value| value.to_str().ok())
{
builder = builder.authority(host);
} else if let Some(host) =
parts.headers.get(header::HOST).and_then(|value| value.to_str().ok())
{
builder = builder.authority(host);
} else if let Some(authority) = uri.authority().cloned() {
builder = builder.authority(authority);
} else if let Ok(ConnectInfo(socket_addr)) =
ConnectInfo::<SocketAddr>::from_request_parts(parts, state).await
{
builder = builder.authority(socket_addr.to_string());
}
if let Some(path_and_query) = uri.path_and_query().cloned() {
builder = builder.path_and_query(path_and_query);
}
Ok(FullUri(builder.build().unwrap_or(uri)))
}
}
+3 -15
View File
@@ -4,8 +4,6 @@ use objdiff_core::bindings::report::{Measures, Report, ReportCategory, ReportUni
use serde::Serialize;
use time::UtcDateTime;
use crate::db::UnitKey;
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
pub struct Project {
pub id: u64,
@@ -64,19 +62,6 @@ pub struct Commit {
pub timestamp: UtcDateTime,
}
impl From<&octocrab::models::workflows::HeadCommit> for Commit {
fn from(commit: &octocrab::models::workflows::HeadCommit) -> Self {
Self {
sha: commit.id.clone(),
timestamp: UtcDateTime::from_unix_timestamp(
commit.timestamp.to_utc().timestamp_millis(),
)
.unwrap_or_else(|_| UtcDateTime::now()),
message: (!commit.message.is_empty()).then(|| commit.message.clone()),
}
}
}
#[derive(Debug, Clone)]
pub struct ReportFile<R> {
pub commit: Commit,
@@ -95,6 +80,9 @@ pub struct ReportInner<U> {
pub categories: Vec<ReportCategory>,
}
// BLAKE3 hash of the unit data
pub type UnitKey = [u8; 32];
pub type CachedReport = ReportInner<UnitKey>;
pub type FullReport = ReportInner<Arc<ReportUnit>>;
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "decomp-dev-db"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
anyhow.workspace = true
blake3 = "1.5"
decomp-dev-core = { path = "../core" }
futures-util.workspace = true
hex.workspace = true
moka = { version = "0.12", features = ["future"] }
objdiff-core.workspace = true
prost = "0.13"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "time"] }
time.workspace = true
tracing.workspace = true
zstd = "0.13"
serde_json.workspace = true
+25 -29
View File
@@ -1,23 +1,22 @@
use core::mem;
use std::{borrow::Cow, cell::RefCell, collections::HashMap, sync::Arc};
use anyhow::{anyhow, bail, Context, Result};
use futures_util::TryStreamExt;
use moka::future::Cache;
use objdiff_core::bindings::report::{Report, ReportUnit, REPORT_VERSION};
use prost::Message;
use sqlx::{
migrate::MigrateDatabase, Connection, Executor, Pool, Row, Sqlite, SqliteConnection, SqlitePool,
};
use time::{macros::format_description, OffsetDateTime, UtcDateTime};
use crate::{
use anyhow::{Context, Result, anyhow, bail};
use decomp_dev_core::{
config::DbConfig,
models::{
CachedReport, CachedReportFile, Commit, FrogressMapping, FullReport, FullReportFile,
Project, ProjectInfo,
Project, ProjectInfo, UnitKey,
},
};
use futures_util::TryStreamExt;
use moka::future::Cache;
use objdiff_core::bindings::report::{REPORT_VERSION, Report, ReportUnit};
use prost::Message;
use sqlx::{
Connection, Executor, Pool, Row, Sqlite, SqliteConnection, SqlitePool, migrate::MigrateDatabase,
};
use time::{OffsetDateTime, UtcDateTime, macros::format_description};
#[derive(Clone)]
pub struct Database {
@@ -34,9 +33,6 @@ struct ReportKey {
version: String,
}
// BLAKE3 hash of the unit data
pub type UnitKey = [u8; 32];
// Maximum number of bind parameters in a single query (SQLite limit)
const BIND_LIMIT: usize = 32766;
@@ -49,7 +45,7 @@ impl Database {
}
let pool =
SqlitePool::connect(&config.url).await.context("Failed to connect to database")?;
sqlx::migrate!("./migrations")
sqlx::migrate!("../../migrations")
.run(&pool)
.await
.context("Failed to run database migrations")?;
@@ -104,8 +100,8 @@ impl Database {
project.default_category,
project.default_version,
)
.execute(&mut *tx)
.await?;
.execute(&mut *tx)
.await?;
report.migrate()?;
let units = mem::take(&mut report.units);
let data = compress(&report.encode_to_vec());
@@ -126,10 +122,10 @@ impl Database {
data,
report.version,
)
.fetch_one(&mut *tx)
.await?
.id;
Self::insert_report_units(&mut *tx, &units, report_id).await?;
.fetch_one(&mut *tx)
.await?
.id;
Self::insert_report_units(&mut tx, &units, report_id).await?;
tx.commit().await?;
// self.report_cache
// .insert(
@@ -369,8 +365,8 @@ impl Database {
owner,
repo
)
.fetch_optional(&mut *conn)
.await?
.fetch_optional(&mut *conn)
.await?
{
Some(row) => Project {
id: row.id as u64,
@@ -386,7 +382,7 @@ impl Database {
},
None => return Ok(None),
};
self.get_project_info_inner(&mut *conn, project, commit).await
self.get_project_info_inner(&mut conn, project, commit).await
}
pub async fn get_project_info_by_id(
@@ -404,8 +400,8 @@ impl Database {
"#,
project_id_db
)
.fetch_optional(&mut *conn)
.await?
.fetch_optional(&mut *conn)
.await?
{
Some(row) => Project {
id: project_id,
@@ -421,7 +417,7 @@ impl Database {
},
None => return Ok(None),
};
self.get_project_info_inner(&mut *conn, project, commit).await
self.get_project_info_inner(&mut conn, project, commit).await
}
async fn get_project_info_inner(
@@ -761,7 +757,7 @@ impl Database {
.execute(&mut *tx)
.await?;
// Insert updated report units
Self::insert_report_units(&mut *tx, &units, report_id).await?;
Self::insert_report_units(&mut tx, &units, report_id).await?;
tx.commit().await?;
}
Ok(())
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "decomp-dev-github"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
anyhow.workspace = true
axum.workspace = true
decomp-dev-core = { path = "../core" }
decomp-dev-db = { path = "../db" }
futures-util.workspace = true
hex = "0.4"
hmac = "0.12"
http = "1.3"
jsonwebtoken = "9.3"
objdiff-core.workspace = true
octocrab.workspace = true
regex = "1.11"
serde.workspace = true
sha2 = "0.10"
time.workspace = true
tokio.workspace = true
tracing.workspace = true
zip = { version = "2.6", default-features = false, features = ["flate2", "deflate-flate2"] }
serde_json.workspace = true
+283
View File
@@ -0,0 +1,283 @@
use std::cmp::Ordering;
use anyhow::Result;
use decomp_dev_core::models::{Commit, FullReportFile};
use objdiff_core::bindings::report::{
ChangeItem, ChangeItemInfo, ChangeUnit, Changes, Report, ReportItem, ReportUnit,
};
use crate::ProcessArtifactResult;
pub fn generate_changes(previous: &Report, current: &Report) -> Result<Changes> {
let mut changes = Changes { from: previous.measures, to: current.measures, units: vec![] };
for prev_unit in &previous.units {
let curr_unit = current.units.iter().find(|u| u.name == prev_unit.name);
let sections = process_items(prev_unit, curr_unit, |u| &u.sections);
let functions = process_items(prev_unit, curr_unit, |u| &u.functions);
let prev_measures = prev_unit.measures;
let curr_measures = curr_unit.and_then(|u| u.measures);
if !functions.is_empty() || prev_measures != curr_measures {
changes.units.push(ChangeUnit {
name: prev_unit.name.clone(),
from: prev_measures,
to: curr_measures,
sections,
functions,
metadata: curr_unit
.as_ref()
.and_then(|u| u.metadata.clone())
.or_else(|| prev_unit.metadata.clone()),
});
}
}
for curr_unit in &current.units {
if !previous.units.iter().any(|u| u.name == curr_unit.name) {
changes.units.push(ChangeUnit {
name: curr_unit.name.clone(),
from: None,
to: curr_unit.measures,
sections: process_new_items(&curr_unit.sections),
functions: process_new_items(&curr_unit.functions),
metadata: curr_unit.metadata.clone(),
});
}
}
Ok(changes)
}
fn process_items<F: Fn(&ReportUnit) -> &Vec<ReportItem>>(
prev_unit: &ReportUnit,
curr_unit: Option<&ReportUnit>,
getter: F,
) -> Vec<ChangeItem> {
let prev_items = getter(prev_unit);
let mut items = vec![];
if let Some(curr_unit) = curr_unit {
let curr_items = getter(curr_unit);
for prev_func in prev_items {
let prev_func_info = ChangeItemInfo::from(prev_func);
let prev_func_address = prev_func.metadata.as_ref().and_then(|m| m.virtual_address);
let curr_func = curr_items.iter().find(|f| {
f.name == prev_func.name
|| prev_func_address.is_some_and(|a| {
f.metadata.as_ref().and_then(|m| m.virtual_address).is_some_and(|b| a == b)
})
});
if let Some(curr_func) = curr_func {
let curr_func_info = ChangeItemInfo::from(curr_func);
if prev_func_info != curr_func_info {
items.push(ChangeItem {
name: curr_func.name.clone(),
from: Some(prev_func_info),
to: Some(curr_func_info),
metadata: curr_func.metadata.clone(),
});
}
} else {
items.push(ChangeItem {
name: prev_func.name.clone(),
from: Some(prev_func_info),
to: None,
metadata: prev_func.metadata.clone(),
});
}
}
for curr_func in curr_items {
let curr_func_address = curr_func.metadata.as_ref().and_then(|m| m.virtual_address);
if !prev_items.iter().any(|f| {
f.name == curr_func.name
|| curr_func_address.is_some_and(|a| {
f.metadata.as_ref().and_then(|m| m.virtual_address).is_some_and(|b| a == b)
})
}) {
items.push(ChangeItem {
name: curr_func.name.clone(),
from: None,
to: Some(ChangeItemInfo::from(curr_func)),
metadata: curr_func.metadata.clone(),
});
}
}
} else {
for prev_func in prev_items {
items.push(ChangeItem {
name: prev_func.name.clone(),
from: Some(ChangeItemInfo::from(prev_func)),
to: None,
metadata: prev_func.metadata.clone(),
});
}
}
items
}
fn process_new_items(items: &[ReportItem]) -> Vec<ChangeItem> {
items
.iter()
.map(|item| ChangeItem {
name: item.name.clone(),
from: None,
to: Some(ChangeItemInfo::from(item)),
metadata: item.metadata.clone(),
})
.collect()
}
fn measure_line_matched(
name: &str,
from: u64,
from_percent: f32,
to: u64,
to_percent: f32,
) -> String {
let emoji = if to > from { "📈" } else { "📉" };
let percent_diff = to_percent - from_percent;
let percent_str = if percent_diff < 0.0 {
format!("{percent_diff:.2}%")
} else {
format!("+{percent_diff:.2}%")
};
let bytes_diff = to as i64 - from as i64;
let bytes_str = match bytes_diff.cmp(&0) {
Ordering::Less => bytes_diff.to_string(),
Ordering::Equal | Ordering::Greater => format!("+{bytes_diff}"),
};
format!("{emoji} **{name}**: {to_percent:.2}% ({percent_str}, {bytes_str} bytes)\n")
}
fn measure_line_bytes(name: &str, from: u64, to: u64) -> String {
let diff = to as i64 - from as i64;
let diff_str = match diff.cmp(&0) {
Ordering::Less => diff.to_string(),
Ordering::Equal | Ordering::Greater => format!("+{diff}"),
};
format!("**{name}**: {to} bytes ({diff_str} bytes)\n")
}
fn measure_line_simple(name: &str, from: u64, to: u64) -> String {
let diff = to as i64 - from as i64;
let diff_str = match diff.cmp(&0) {
Ordering::Less => diff.to_string(),
Ordering::Equal | Ordering::Greater => format!("+{diff}"),
};
format!("**{name}**: {to} ({diff_str})\n")
}
pub fn generate_comment(
from: &FullReportFile,
to: &ProcessArtifactResult,
to_commit: &Commit,
changes: Changes,
) -> String {
let mut comment = format!(
"### Report for {} ({} - {})\n\n",
to.version,
&from.commit.sha[..7],
&to_commit.sha[..7]
);
let mut measure_written = false;
let from_measures = from.report.measures;
let to_measures = to.report.measures.unwrap_or_default();
if from_measures.total_code != to_measures.total_code {
comment.push_str(&measure_line_bytes(
"Total code",
from_measures.total_code,
to_measures.total_code,
));
measure_written = true;
}
if from_measures.total_functions != to_measures.total_functions {
comment.push_str(&measure_line_simple(
"Total functions",
from_measures.total_functions as u64,
to_measures.total_functions as u64,
));
measure_written = true;
}
if from_measures.matched_code != to_measures.matched_code {
comment.push_str(&measure_line_matched(
"Matched code",
from_measures.matched_code,
from_measures.matched_code_percent,
to_measures.matched_code,
to_measures.matched_code_percent,
));
measure_written = true;
}
if from_measures.complete_code != to_measures.complete_code {
comment.push_str(&measure_line_matched(
"Linked code",
from_measures.complete_code,
from_measures.complete_code_percent,
to_measures.complete_code,
to_measures.complete_code_percent,
));
measure_written = true;
}
if measure_written {
comment.push('\n');
}
let mut total_changes = 0;
let mut iter = changes.units.into_iter().flat_map(|mut unit| {
let functions = core::mem::take(&mut unit.functions);
functions.into_iter().map(move |f| (unit.clone(), f))
});
for (unit, item) in iter.by_ref() {
let (from, to) = match (item.from, item.to) {
(Some(from), Some(to)) => (from, to),
(None, Some(to)) => (ChangeItemInfo::default(), to),
(Some(from), None) => (from, ChangeItemInfo::default()),
(None, None) => continue,
};
let emoji = if to.fuzzy_match_percent == 100.0 {
""
} else if to.fuzzy_match_percent > from.fuzzy_match_percent {
"📈"
} else {
"📉"
};
let from_bytes = ((from.fuzzy_match_percent as f64 / 100.0) * from.size as f64) as u64;
let to_bytes = ((to.fuzzy_match_percent as f64 / 100.0) * to.size as f64) as u64;
let bytes_diff = to_bytes as i64 - from_bytes as i64;
let bytes_str = match bytes_diff.cmp(&0) {
Ordering::Less => bytes_diff.to_string(),
Ordering::Equal => "0".to_string(),
Ordering::Greater => format!("+{}", bytes_diff),
};
let name =
item.metadata.as_ref().and_then(|m| m.demangled_name.as_deref()).unwrap_or(&item.name);
comment.push_str(&format!(
"{emoji} `{} | {}` {} bytes -> {:.2}%\n",
unit.name, name, bytes_str, to.fuzzy_match_percent
));
total_changes += 1;
if total_changes >= 30 {
break;
}
}
let remaining = iter.count();
if remaining > 0 {
comment.push_str(&format!("...and {} more items\n", remaining));
} else if total_changes == 0 {
comment.push_str("No changes\n");
}
comment
}
#[allow(unused)]
fn platform_name(platform: &str) -> &str {
match platform {
"gc" => "GameCube",
"wii" => "Wii",
"n64" => "Nintendo 64",
"switch" => "Nintendo Switch",
"3ds" => "Nintendo 3DS",
"nds" => "Nintendo DS",
"gba" => "Game Boy Advance",
"gbc" => "Game Boy Color",
"ps" => "PlayStation",
"ps2" => "PlayStation 2",
_ => platform,
}
}
+33 -29
View File
@@ -1,5 +1,8 @@
pub mod changes;
pub mod webhook;
use std::{
collections::{hash_map::Entry, HashMap},
collections::{HashMap, hash_map::Entry},
ffi::OsStr,
io::{Cursor, Read},
pin::pin,
@@ -7,26 +10,26 @@ use std::{
};
use anyhow::{Context, Result};
use axum::http::StatusCode;
use decomp_dev_core::{
config::GitHubConfig,
models::{Commit, Project},
};
use decomp_dev_db::Database;
use futures_util::TryStreamExt;
use http::StatusCode;
use objdiff_core::bindings::report::Report;
use octocrab::{
models::{repos::RepoCommitPage, ArtifactId, InstallationId, RunId},
params::actions::ArchiveFormat,
GitHubError, Octocrab,
models::{ArtifactId, InstallationId, RunId, repos::RepoCommitPage, workflows::HeadCommit},
params::actions::ArchiveFormat,
};
use regex::Regex;
use time::UtcDateTime;
use tokio::{
sync::{Mutex, Semaphore},
task::JoinSet,
};
use crate::{
config::GitHubConfig,
models::{Commit, Project},
AppState,
};
#[derive(Clone)]
pub struct GitHub {
pub client: Octocrab,
@@ -153,15 +156,13 @@ impl GitHub {
}
}
pub async fn run(state: &AppState, repo_id: u64, stop_run_id: u64) -> Result<()> {
let mut project_info = state
.db
pub async fn run(github: &GitHub, db: &Database, repo_id: u64, stop_run_id: u64) -> Result<()> {
let mut project_info = db
.get_project_info_by_id(repo_id, None)
.await
.context("Failed to fetch project info")?
.with_context(|| format!("Failed to fetch project info for ID {}", repo_id))?;
let repo = state
.github
let repo = github
.client
.repos_by_id(project_info.project.id)
.get()
@@ -178,12 +179,8 @@ pub async fn run(state: &AppState, repo_id: u64, stop_run_id: u64) -> Result<()>
owner.login,
repo.name
);
state
.db
.update_project_owner_repo(project_info.project.id, &owner.login, &repo.name)
.await?;
project_info = state
.db
db.update_project_owner_repo(project_info.project.id, &owner.login, &repo.name).await?;
project_info = db
.get_project_info_by_id(repo_id, None)
.await
.context("Failed to fetch project info")?
@@ -192,7 +189,7 @@ pub async fn run(state: &AppState, repo_id: u64, stop_run_id: u64) -> Result<()>
let project = &project_info.project;
tracing::debug!("Refreshing project {}/{}", project.owner, project.repo);
let client = state.github.client_for(&project.owner).await?;
let client = github.client_for(&project.owner).await?;
let workflow_ids = if let Some(workflow_id) = &project.workflow_id {
vec![workflow_id.clone()]
@@ -234,7 +231,7 @@ pub async fn run(state: &AppState, repo_id: u64, stop_run_id: u64) -> Result<()>
..
}) =>
{
break
break;
}
Err(e) => {
return Err(e)
@@ -274,9 +271,9 @@ pub async fn run(state: &AppState, repo_id: u64, stop_run_id: u64) -> Result<()>
let sem = sem.clone();
let project = project.clone();
let client = client.clone();
let db = state.db.clone();
let db = db.clone();
let run_id = run.id;
let commit = Commit::from(&run.head_commit);
let commit = commit_from_head_commit(&run.head_commit);
set.spawn(async move {
let _permit = sem.acquire().await.unwrap();
match db.report_exists(project.id, &commit.sha).await {
@@ -310,9 +307,7 @@ pub async fn run(state: &AppState, repo_id: u64, stop_run_id: u64) -> Result<()>
);
for artifact in artifacts {
let start = std::time::Instant::now();
state
.db
.insert_report(&project, &commit, &artifact.version, *artifact.report)
db.insert_report(project, &commit, &artifact.version, *artifact.report)
.await?;
let duration = start.elapsed();
tracing::info!(
@@ -340,7 +335,7 @@ pub async fn run(state: &AppState, repo_id: u64, stop_run_id: u64) -> Result<()>
if found_artifacts {
if project.workflow_id.is_none() {
state.db.update_project_workflow_id(project.id, workflow_id).await?;
db.update_project_workflow_id(project.id, workflow_id).await?;
}
break;
}
@@ -485,3 +480,12 @@ async fn download_artifact(
}
Ok(vec![])
}
pub fn commit_from_head_commit(commit: &HeadCommit) -> Commit {
Commit {
sha: commit.id.clone(),
timestamp: UtcDateTime::from_unix_timestamp(commit.timestamp.to_utc().timestamp_millis())
.unwrap_or_else(|_| UtcDateTime::now()),
message: (!commit.message.is_empty()).then(|| commit.message.clone()),
}
}
@@ -1,4 +1,4 @@
use std::{cmp::Ordering, fmt::Display, mem::take};
use std::fmt::Display;
use anyhow::{Context, Result};
use axum::{
@@ -7,33 +7,38 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use decomp_dev_core::{AppError, config::GitHubConfig};
use decomp_dev_db::Database;
use hmac::{Hmac, Mac};
use objdiff_core::bindings::report::{
ChangeItem, ChangeItemInfo, ChangeUnit, Changes, Report, ReportItem, ReportUnit,
};
use octocrab::{
Octocrab,
models::{
pulls::PullRequest,
webhook_events::{
EventInstallation, WebhookEvent, WebhookEventPayload,
payload::{
InstallationWebhookEventAction, PullRequestWebhookEventAction,
WorkflowRunWebhookEventAction,
},
EventInstallation, WebhookEvent, WebhookEventPayload,
},
workflows::{Run, WorkFlow},
},
Octocrab,
};
use sha2::Sha256;
use crate::{
github::{process_workflow_run, ProcessArtifactResult, ProcessWorkflowRunResult},
handlers::AppError,
models::{Commit, FullReportFile},
AppState,
GitHub, ProcessWorkflowRunResult,
changes::{generate_changes, generate_comment},
commit_from_head_commit, process_workflow_run,
};
#[derive(Clone)]
pub struct WebhookState {
pub config: GitHubConfig,
pub db: Database,
pub github: GitHub,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct RunWithPullRequests {
#[serde(flatten)]
@@ -41,9 +46,7 @@ pub struct RunWithPullRequests {
pub pull_requests: Vec<PullRequest>,
}
pub async fn webhook(
GitHubEvent { event, state }: GitHubEvent<AppState>,
) -> Result<Response, AppError> {
pub async fn webhook(GitHubEvent { event, state }: GitHubEvent) -> Result<Response, AppError> {
let Some(installations) = &state.github.installations else {
tracing::warn!("Received webhook event {:?} with no GitHub app config", event.kind);
return Ok((StatusCode::OK, "No app config").into_response());
@@ -160,7 +163,7 @@ pub async fn webhook(
}
async fn handle_workflow_run_completed(
state: &AppState,
state: &WebhookState,
client: Octocrab,
_workflow: WorkFlow,
workflow_run: RunWithPullRequests,
@@ -185,7 +188,7 @@ async fn handle_workflow_run_completed(
return Ok(());
}
let commit = Commit::from(&workflow_run.head_commit);
let commit = commit_from_head_commit(&workflow_run.head_commit);
if workflow_run.event == "push"
&& match &repository.default_branch {
Some(default_branch) => default_branch == &workflow_run.head_branch,
@@ -261,7 +264,7 @@ async fn handle_workflow_run_completed(
};
let report_file = state.db.upgrade_report(&cached_report).await?;
let report = report_file.report.flatten();
let changes = changes(&report, &artifact.report)?;
let changes = generate_changes(&report, &artifact.report)?;
let comment_text = generate_comment(&report_file, artifact, &commit, changes);
let existing_comment = existing_comments
.items
@@ -292,7 +295,7 @@ async fn handle_workflow_run_completed(
}
async fn handle_pull_request_update(
_state: &AppState,
_state: &WebhookState,
_client: Octocrab,
_pull_request: PullRequest,
) -> Result<()> {
@@ -302,16 +305,16 @@ async fn handle_pull_request_update(
}
/// Verify and extract GitHub Event Payload.
#[derive(Debug, Clone)]
#[derive(Clone)]
#[must_use]
pub struct GitHubEvent<S> {
pub struct GitHubEvent {
pub event: WebhookEvent,
pub state: S,
pub state: WebhookState,
}
impl<S> FromRequest<S> for GitHubEvent<S>
impl<S> FromRequest<S> for GitHubEvent
where
AppState: FromRef<S>,
WebhookState: FromRef<S>,
S: Send + Sync + Clone,
{
type Rejection = Response;
@@ -327,8 +330,8 @@ where
.and_then(|v| v.to_str().ok())
.ok_or_else(|| err("X-GitHub-Event header missing"))?
.to_string();
let app_state = AppState::from_ref(state);
let body = if let Some(app_config) = &app_state.config.github.app {
let inner = WebhookState::from_ref(state);
let body = if let Some(app_config) = &inner.config.app {
let signature_sha256 = req
.headers()
.get("X-Hub-Signature-256")
@@ -352,280 +355,6 @@ where
};
let value = WebhookEvent::try_from_header_and_body(&event, &body)
.map_err(|_| err("error parsing body"))?;
Ok(GitHubEvent { event: value, state: state.clone() })
}
}
fn changes(previous: &Report, current: &Report) -> Result<Changes> {
let mut changes = Changes { from: previous.measures, to: current.measures, units: vec![] };
for prev_unit in &previous.units {
let curr_unit = current.units.iter().find(|u| u.name == prev_unit.name);
let sections = process_items(prev_unit, curr_unit, |u| &u.sections);
let functions = process_items(prev_unit, curr_unit, |u| &u.functions);
let prev_measures = prev_unit.measures;
let curr_measures = curr_unit.and_then(|u| u.measures);
if !functions.is_empty() || prev_measures != curr_measures {
changes.units.push(ChangeUnit {
name: prev_unit.name.clone(),
from: prev_measures,
to: curr_measures,
sections,
functions,
metadata: curr_unit
.as_ref()
.and_then(|u| u.metadata.clone())
.or_else(|| prev_unit.metadata.clone()),
});
}
}
for curr_unit in &current.units {
if !previous.units.iter().any(|u| u.name == curr_unit.name) {
changes.units.push(ChangeUnit {
name: curr_unit.name.clone(),
from: None,
to: curr_unit.measures,
sections: process_new_items(&curr_unit.sections),
functions: process_new_items(&curr_unit.functions),
metadata: curr_unit.metadata.clone(),
});
}
}
Ok(changes)
}
fn process_items<F: Fn(&ReportUnit) -> &Vec<ReportItem>>(
prev_unit: &ReportUnit,
curr_unit: Option<&ReportUnit>,
getter: F,
) -> Vec<ChangeItem> {
let prev_items = getter(prev_unit);
let mut items = vec![];
if let Some(curr_unit) = curr_unit {
let curr_items = getter(curr_unit);
for prev_func in prev_items {
let prev_func_info = ChangeItemInfo::from(prev_func);
let prev_func_address = prev_func.metadata.as_ref().and_then(|m| m.virtual_address);
let curr_func = curr_items.iter().find(|f| {
f.name == prev_func.name
|| prev_func_address.is_some_and(|a| {
f.metadata.as_ref().and_then(|m| m.virtual_address).is_some_and(|b| a == b)
})
});
if let Some(curr_func) = curr_func {
let curr_func_info = ChangeItemInfo::from(curr_func);
if prev_func_info != curr_func_info {
items.push(ChangeItem {
name: curr_func.name.clone(),
from: Some(prev_func_info),
to: Some(curr_func_info),
metadata: curr_func.metadata.clone(),
});
}
} else {
items.push(ChangeItem {
name: prev_func.name.clone(),
from: Some(prev_func_info),
to: None,
metadata: prev_func.metadata.clone(),
});
}
}
for curr_func in curr_items {
let curr_func_address = curr_func.metadata.as_ref().and_then(|m| m.virtual_address);
if !prev_items.iter().any(|f| {
f.name == curr_func.name
|| curr_func_address.is_some_and(|a| {
f.metadata.as_ref().and_then(|m| m.virtual_address).is_some_and(|b| a == b)
})
}) {
items.push(ChangeItem {
name: curr_func.name.clone(),
from: None,
to: Some(ChangeItemInfo::from(curr_func)),
metadata: curr_func.metadata.clone(),
});
}
}
} else {
for prev_func in prev_items {
items.push(ChangeItem {
name: prev_func.name.clone(),
from: Some(ChangeItemInfo::from(prev_func)),
to: None,
metadata: prev_func.metadata.clone(),
});
}
}
items
}
fn process_new_items(items: &[ReportItem]) -> Vec<ChangeItem> {
items
.iter()
.map(|item| ChangeItem {
name: item.name.clone(),
from: None,
to: Some(ChangeItemInfo::from(item)),
metadata: item.metadata.clone(),
})
.collect()
}
fn measure_line_matched(
name: &str,
from: u64,
from_percent: f32,
to: u64,
to_percent: f32,
) -> String {
let emoji = if to > from { "📈" } else { "📉" };
let percent_diff = to_percent - from_percent;
let percent_str = if percent_diff < 0.0 {
format!("{percent_diff:.2}%")
} else {
format!("+{percent_diff:.2}%")
};
let bytes_diff = to as i64 - from as i64;
let bytes_str = match bytes_diff.cmp(&0) {
Ordering::Less => bytes_diff.to_string(),
Ordering::Equal | Ordering::Greater => format!("+{bytes_diff}"),
};
format!("{emoji} **{name}**: {to_percent:.2}% ({percent_str}, {bytes_str} bytes)\n")
}
fn measure_line_bytes(name: &str, from: u64, to: u64) -> String {
let diff = to as i64 - from as i64;
let diff_str = match diff.cmp(&0) {
Ordering::Less => diff.to_string(),
Ordering::Equal | Ordering::Greater => format!("+{diff}"),
};
format!("**{name}**: {to} bytes ({diff_str} bytes)\n")
}
fn measure_line_simple(name: &str, from: u64, to: u64) -> String {
let diff = to as i64 - from as i64;
let diff_str = match diff.cmp(&0) {
Ordering::Less => diff.to_string(),
Ordering::Equal | Ordering::Greater => format!("+{diff}"),
};
format!("**{name}**: {to} ({diff_str})\n")
}
fn generate_comment(
from: &FullReportFile,
to: &ProcessArtifactResult,
to_commit: &Commit,
changes: Changes,
) -> String {
let mut comment = format!(
"### Report for {} ({} - {})\n\n",
to.version,
&from.commit.sha[..7],
&to_commit.sha[..7]
);
let mut measure_written = false;
let from_measures = from.report.measures;
let to_measures = to.report.measures.unwrap_or_default();
if from_measures.total_code != to_measures.total_code {
comment.push_str(&measure_line_bytes(
"Total code",
from_measures.total_code,
to_measures.total_code,
));
measure_written = true;
}
if from_measures.total_functions != to_measures.total_functions {
comment.push_str(&measure_line_simple(
"Total functions",
from_measures.total_functions as u64,
to_measures.total_functions as u64,
));
measure_written = true;
}
if from_measures.matched_code != to_measures.matched_code {
comment.push_str(&measure_line_matched(
"Matched code",
from_measures.matched_code,
from_measures.matched_code_percent,
to_measures.matched_code,
to_measures.matched_code_percent,
));
measure_written = true;
}
if from_measures.complete_code != to_measures.complete_code {
comment.push_str(&measure_line_matched(
"Linked code",
from_measures.complete_code,
from_measures.complete_code_percent,
to_measures.complete_code,
to_measures.complete_code_percent,
));
measure_written = true;
}
if measure_written {
comment.push('\n');
}
let mut total_changes = 0;
let mut iter = changes.units.into_iter().flat_map(|mut unit| {
let functions = take(&mut unit.functions);
functions.into_iter().map(move |f| (unit.clone(), f))
});
for (unit, item) in iter.by_ref() {
let (from, to) = match (item.from, item.to) {
(Some(from), Some(to)) => (from, to),
(None, Some(to)) => (ChangeItemInfo::default(), to),
(Some(from), None) => (from, ChangeItemInfo::default()),
(None, None) => continue,
};
let emoji = if to.fuzzy_match_percent == 100.0 {
""
} else if to.fuzzy_match_percent > from.fuzzy_match_percent {
"📈"
} else {
"📉"
};
let from_bytes = ((from.fuzzy_match_percent as f64 / 100.0) * from.size as f64) as u64;
let to_bytes = ((to.fuzzy_match_percent as f64 / 100.0) * to.size as f64) as u64;
let bytes_diff = to_bytes as i64 - from_bytes as i64;
let bytes_str = match bytes_diff.cmp(&0) {
Ordering::Less => bytes_diff.to_string(),
Ordering::Equal => "0".to_string(),
Ordering::Greater => format!("+{}", bytes_diff),
};
let name =
item.metadata.as_ref().and_then(|m| m.demangled_name.as_deref()).unwrap_or(&item.name);
comment.push_str(&format!(
"{emoji} `{} | {}` {} bytes -> {:.2}%\n",
unit.name, name, bytes_str, to.fuzzy_match_percent
));
total_changes += 1;
if total_changes >= 30 {
break;
}
}
let remaining = iter.count();
if remaining > 0 {
comment.push_str(&format!("...and {} more items\n", remaining));
} else if total_changes == 0 {
comment.push_str("No changes\n");
}
comment
}
#[allow(unused)]
fn platform_name(platform: &str) -> &str {
match platform {
"gc" => "GameCube",
"wii" => "Wii",
"n64" => "Nintendo 64",
"switch" => "Nintendo Switch",
"3ds" => "Nintendo 3DS",
"nds" => "Nintendo DS",
"gba" => "Game Boy Advance",
"gbc" => "Game Boy Color",
"ps" => "PlayStation",
"ps2" => "PlayStation 2",
_ => platform,
Ok(GitHubEvent { event: value, state: inner })
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "decomp-dev-images"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
anyhow.workspace = true
resvg = "0.45"
tracing.workspace = true
objdiff-core.workspace = true
image.workspace = true
serde.workspace = true
badge-maker = "0.3"
axum.workspace = true
decomp-dev-core = { path = "../core" }
tokio.workspace = true
mime.workspace = true
streemap = "0.1"
palette = "0.7"

Some files were not shown because too many files have changed in this diff Show More