fix: make the workspace pass the CI lint/test gate
Some checks failed
deploy / Build api + worker + web (push) Failing after 5m59s
deploy / Deploy moments-api to nikola (push) Has been skipped
deploy / Deploy moments-worker to frootmig (push) Has been skipped
deploy / Deploy web to oolon (push) Has been skipped

The new Gitea Actions build gate runs `cargo fmt --check`, `clippy -D warnings`,
and `cargo test` — stricter than the old deploy.sh, which only `cargo build`d.
That surfaced pre-existing drift that never compiled under the test/clippy
profile:

- apply rustfmt across the workspace (formatting only, no logic changes)
- moments-data: add the missing `prune_events` to the test-only `NoopWriter`
  stub (the EventWriter trait gained it with the blog-prune feature; a plain
  `cargo build` never compiles the `#[cfg(test)]` stub, so it went stale)
- moments-api: `.max().min()` -> `.clamp()`, and build `usvg::Options` with
  struct-update syntax instead of post-Default field assignment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
This commit is contained in:
2026-06-25 13:00:40 +03:00
parent 1b753f991f
commit 3761333ac4
16 changed files with 378 additions and 191 deletions

View File

@@ -11,7 +11,10 @@ use chrono::{DateTime, Datelike, NaiveDate, Utc};
use clap::Parser;
use moments_core::{EventReader, reshape};
use moments_data::PgStore;
use moments_entities::{BlogPost, BlogPostSummary, DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
use moments_entities::{
BlogPost, BlogPostSummary, DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount,
ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem,
};
use serde::Deserialize;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing::info;
@@ -104,11 +107,7 @@ async fn list_events(
State(state): State<AppState>,
Query(params): Query<EventsQueryParams>,
) -> Result<Json<Vec<TimelineItem>>, ApiError> {
let sources = params
.source
.as_deref()
.map(parse_sources)
.transpose()?;
let sources = params.source.as_deref().map(parse_sources).transpose()?;
let limit = params.limit.unwrap_or(100).clamp(1, 1000);
@@ -128,9 +127,7 @@ async fn list_events(
Ok(Json(items))
}
async fn list_sources(
State(state): State<AppState>,
) -> Result<Json<Vec<SourceSummary>>, ApiError> {
async fn list_sources(State(state): State<AppState>) -> Result<Json<Vec<SourceSummary>>, ApiError> {
let summaries = state
.store
.source_summaries(/* include_private */ true)
@@ -161,7 +158,11 @@ async fn blog_events(state: &AppState) -> Result<Vec<Event>, ApiError> {
}
fn payload_str<'a>(event: &'a Event, key: &str) -> &'a str {
event.payload.get(key).and_then(|v| v.as_str()).unwrap_or("")
event
.payload
.get(key)
.and_then(|v| v.as_str())
.unwrap_or("")
}
async fn list_blog_posts(
@@ -212,8 +213,14 @@ async fn daily_counts(
Query(params): Query<DailyCountsParams>,
) -> Result<Json<Vec<DailyCount>>, ApiError> {
let to = params.to.unwrap_or_else(|| Utc::now().date_naive());
let from = params.from.unwrap_or_else(|| to - chrono::Duration::days(365));
let counts = state.store.daily_counts(from, to, /* include_private */ true).await.map_err(internal)?;
let from = params
.from
.unwrap_or_else(|| to - chrono::Duration::days(365));
let counts = state
.store
.daily_counts(from, to, /* include_private */ true)
.await
.map_err(internal)?;
Ok(Json(counts))
}
@@ -222,8 +229,14 @@ async fn language_daily_counts(
Query(params): Query<DailyCountsParams>,
) -> Result<Json<Vec<LanguageDailyCount>>, ApiError> {
let to = params.to.unwrap_or_else(|| Utc::now().date_naive());
let from = params.from.unwrap_or_else(|| to - chrono::Duration::days(365));
let counts = state.store.language_daily_counts(from, to, /* include_private */ true).await.map_err(internal)?;
let from = params
.from
.unwrap_or_else(|| to - chrono::Duration::days(365));
let counts = state
.store
.language_daily_counts(from, to, /* include_private */ true)
.await
.map_err(internal)?;
Ok(Json(counts))
}
@@ -242,20 +255,30 @@ async fn hourly_avgs(
Query(params): Query<HourlyAvgsParams>,
) -> Result<Json<Vec<HourlyAvg>>, ApiError> {
let to = params.to.unwrap_or_else(|| Utc::now().date_naive());
let from = params.from.unwrap_or_else(|| to - chrono::Duration::days(365));
let from = params
.from
.unwrap_or_else(|| to - chrono::Duration::days(365));
let tz = params.tz.as_deref().unwrap_or("UTC");
// Validate the tz string before handing it to postgres — a bad name
// here would surface as an opaque 500 from the DB. chrono-tz would do
// it for free but we don't depend on it; instead reject obvious shell
// injection vectors (the value is bound, not interpolated, so this is
// belt-and-braces).
if tz.len() > 64 || tz.chars().any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-'))) {
if tz.len() > 64
|| tz
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-')))
{
return Err(ApiError {
status: StatusCode::BAD_REQUEST,
message: "invalid tz".into(),
});
}
let avgs = state.store.hourly_avgs(from, to, tz, /* include_private */ true).await.map_err(internal)?;
let avgs = state
.store
.hourly_avgs(from, to, tz, /* include_private */ true)
.await
.map_err(internal)?;
Ok(Json(avgs))
}
@@ -266,9 +289,7 @@ async fn repo_languages(
Ok(Json(langs))
}
async fn og_contributions(
State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> {
async fn og_contributions(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
// Get date range from source summaries
let summaries = state
.store
@@ -292,10 +313,11 @@ async fn og_contributions(
let projects = state.store.list_projects().await.map_err(internal)?;
let repo_count = projects.len();
let png = render_contributions_png(&counts, earliest, today, repo_count).map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: e,
})?;
let png =
render_contributions_png(&counts, earliest, today, repo_count).map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: e,
})?;
Ok((
StatusCode::OK,
@@ -332,7 +354,13 @@ fn render_contributions_png(
let cell = step - gap;
let radius = cell / 2.0;
let colors = ["rgba(255,255,255,0.05)", "#0e4429", "#006d32", "#26a641", "#39d353"];
let colors = [
"rgba(255,255,255,0.05)",
"#0e4429",
"#006d32",
"#26a641",
"#39d353",
];
// Build weekly data per year
struct YearRow {
@@ -377,16 +405,24 @@ fn render_contributions_png(
let thresholds = if non_zero.is_empty() {
[1i64, 2, 3]
} else {
let p = |pct: f64| non_zero[(pct * non_zero.len() as f64).min(non_zero.len() as f64 - 1.0) as usize];
let p = |pct: f64| {
non_zero[(pct * non_zero.len() as f64).min(non_zero.len() as f64 - 1.0) as usize]
};
[p(0.25), p(0.5), p(0.75)]
};
let color_for = |count: i64| -> &str {
if count == 0 { colors[0] }
else if count <= thresholds[0] { colors[1] }
else if count <= thresholds[1] { colors[2] }
else if count <= thresholds[2] { colors[3] }
else { colors[4] }
if count == 0 {
colors[0]
} else if count <= thresholds[0] {
colors[1]
} else if count <= thresholds[1] {
colors[2]
} else if count <= thresholds[2] {
colors[3]
} else {
colors[4]
}
};
let n_rows = rows.len();
@@ -425,7 +461,7 @@ fn render_contributions_png(
y = subtitle_y,
));
let label_font_size = (step * 0.7).round().max(8.0).min(14.0);
let label_font_size = (step * 0.7).round().clamp(8.0, 14.0);
for (row_idx, row) in rows.iter().enumerate() {
let y_base = graph_y + (row_idx as f64) * step;
@@ -452,20 +488,23 @@ fn render_contributions_png(
// Rasterize at 1200x630
let mut fontdb = fontdb::Database::new();
fontdb.load_system_fonts();
let mut opts = resvg::usvg::Options::default();
opts.fontdb = std::sync::Arc::new(fontdb);
opts.font_family = "Noto Sans".to_owned();
let tree = resvg::usvg::Tree::from_str(&svg, &opts)
.map_err(|e| format!("svg parse: {e}"))?;
let opts = resvg::usvg::Options {
fontdb: std::sync::Arc::new(fontdb),
font_family: "Noto Sans".to_owned(),
..Default::default()
};
let tree = resvg::usvg::Tree::from_str(&svg, &opts).map_err(|e| format!("svg parse: {e}"))?;
let mut pixmap =
resvg::tiny_skia::Pixmap::new(og_w as u32, og_h as u32).ok_or_else(|| "pixmap alloc failed".to_string())?;
let mut pixmap = resvg::tiny_skia::Pixmap::new(og_w as u32, og_h as u32)
.ok_or_else(|| "pixmap alloc failed".to_string())?;
resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
resvg::render(
&tree,
resvg::tiny_skia::Transform::default(),
&mut pixmap.as_mut(),
);
pixmap
.encode_png()
.map_err(|e| format!("png encode: {e}"))
pixmap.encode_png().map_err(|e| format!("png encode: {e}"))
}
/// Allowlisted forge hosts that the proxy may contact.
@@ -492,7 +531,11 @@ async fn forge_proxy(
}
(format!("https://{host}"), "/api/v1")
}
_ => return Err(ApiError::bad_request(format!("unsupported source: {source}"))),
_ => {
return Err(ApiError::bad_request(format!(
"unsupported source: {source}"
)));
}
};
let url = format!("{base}{api_prefix}/{rest}");
@@ -528,7 +571,10 @@ fn parse_sources(raw: &str) -> Result<Vec<Source>, ApiError> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.parse::<Source>().map_err(|e| ApiError::bad_request(e.to_string())))
.map(|s| {
s.parse::<Source>()
.map_err(|e| ApiError::bad_request(e.to_string()))
})
.collect()
}
@@ -557,6 +603,10 @@ fn internal<E: std::fmt::Display>(e: E) -> ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
(self.status, Json(serde_json::json!({ "error": self.message }))).into_response()
(
self.status,
Json(serde_json::json!({ "error": self.message })),
)
.into_response()
}
}