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

@@ -6,7 +6,10 @@ pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_p
use async_trait::async_trait;
use chrono::NaiveDate;
use moments_entities::{DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, SourceSummary};
use moments_entities::{
DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage,
SourceSummary,
};
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
@@ -18,11 +21,30 @@ pub enum StoreError {
#[async_trait]
pub trait EventReader: Send + Sync {
async fn list_events(&self, query: &EventQuery) -> Result<Vec<Event>, StoreError>;
async fn source_summaries(&self, include_private: bool) -> Result<Vec<SourceSummary>, StoreError>;
async fn source_summaries(
&self,
include_private: bool,
) -> Result<Vec<SourceSummary>, StoreError>;
async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError>;
async fn daily_counts(&self, from: NaiveDate, to: NaiveDate, include_private: bool) -> Result<Vec<DailyCount>, StoreError>;
async fn language_daily_counts(&self, from: NaiveDate, to: NaiveDate, include_private: bool) -> Result<Vec<LanguageDailyCount>, StoreError>;
async fn hourly_avgs(&self, from: NaiveDate, to: NaiveDate, tz: &str, include_private: bool) -> Result<Vec<HourlyAvg>, StoreError>;
async fn daily_counts(
&self,
from: NaiveDate,
to: NaiveDate,
include_private: bool,
) -> Result<Vec<DailyCount>, StoreError>;
async fn language_daily_counts(
&self,
from: NaiveDate,
to: NaiveDate,
include_private: bool,
) -> Result<Vec<LanguageDailyCount>, StoreError>;
async fn hourly_avgs(
&self,
from: NaiveDate,
to: NaiveDate,
tz: &str,
include_private: bool,
) -> Result<Vec<HourlyAvg>, StoreError>;
async fn repo_languages(&self) -> Result<Vec<RepoLanguage>, StoreError>;
}
@@ -34,5 +56,9 @@ pub trait EventWriter: Send + Sync {
/// Delete events of `source` whose id is not in `keep_ids`. For sources
/// whose upstream is authoritative for the full set (e.g. the blog repo),
/// this reconciles deletes and renames that upserts alone never would.
async fn prune_events(&self, source: moments_entities::Source, keep_ids: &[String]) -> Result<usize, StoreError>;
async fn prune_events(
&self,
source: moments_entities::Source,
keep_ids: &[String],
) -> Result<usize, StoreError>;
}

View File

@@ -108,7 +108,8 @@ mod tests {
#[test]
fn excerpt_skips_headings_and_images() {
let md = "# title\n\n![alt](img.jpg)\n\nfirst real paragraph\nwith a wrapped line\n\nsecond";
let md =
"# title\n\n![alt](img.jpg)\n\nfirst real paragraph\nwith a wrapped line\n\nsecond";
assert_eq!(excerpt(md), "first real paragraph with a wrapped line");
}

View File

@@ -73,7 +73,9 @@ mod tests {
assert!(r.contains("filed bug #1158879 in mozilla.org"), "got: {r}");
assert_eq!(
item.subtitle.unwrap(),
vec![TitleSegment::text("Commit Access (Level 1) for Rob Thijssen")]
vec![TitleSegment::text(
"Commit Access (Level 1) for Rob Thijssen"
)]
);
}
}

View File

@@ -40,9 +40,7 @@ pub(crate) fn reshape(event: &Event) -> TimelineItem {
"create_pull_request" => {
pr_action("opened", TimelineIcon::PullRequest, host, repo, content)
}
"close_pull_request" => {
pr_action("closed", TimelineIcon::PullRequest, host, repo, content)
}
"close_pull_request" => pr_action("closed", TimelineIcon::PullRequest, host, repo, content),
"reopen_pull_request" => {
pr_action("reopened", TimelineIcon::PullRequest, host, repo, content)
}
@@ -53,15 +51,13 @@ pub(crate) fn reshape(event: &Event) -> TimelineItem {
"approve_pull_request" => {
pr_action("approved", TimelineIcon::PullRequest, host, repo, content)
}
"reject_pull_request" => {
pr_action(
"requested changes on",
TimelineIcon::PullRequest,
host,
repo,
content,
)
}
"reject_pull_request" => pr_action(
"requested changes on",
TimelineIcon::PullRequest,
host,
repo,
content,
),
"publish_release" => publish_release(host, repo, content),
_ => fallback(host, repo, &event.action),
};
@@ -303,8 +299,7 @@ fn pr_action(
TitleSegment::text(" in "),
repo_link(host, repo),
];
let subtitle =
(!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]);
let subtitle = (!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]);
(icon, title, subtitle, None)
}
@@ -352,8 +347,7 @@ fn comment_on_pr(
TitleSegment::text(" in "),
repo_link(host, repo),
];
let subtitle =
(!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]);
let subtitle = (!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]);
let body = (!body_text.is_empty()).then(|| TimelineBody::Markdown {
text: body_text.to_string(),
});
@@ -364,7 +358,10 @@ fn publish_release(host: &str, repo: Option<&str>, content: Option<&str>) -> Res
let repo = repo.unwrap_or("(unknown repo)");
let name = content.unwrap_or("");
let title = if name.is_empty() {
vec![TitleSegment::text("published a release in "), repo_link(host, repo)]
vec![
TitleSegment::text("published a release in "),
repo_link(host, repo),
]
} else {
vec![
TitleSegment::text(format!("released {name} in ")),
@@ -452,10 +449,7 @@ mod tests {
let item = reshape(&ev("create_issue", raw));
assert_eq!(item.icon, TimelineIcon::Issue);
let r = render(&item);
assert!(
r.contains("opened issue #1 in grenade/moments"),
"got: {r}"
);
assert!(r.contains("opened issue #1 in grenade/moments"), "got: {r}");
assert_eq!(
item.subtitle.unwrap(),
vec![TitleSegment::text(

View File

@@ -13,7 +13,10 @@ pub(crate) fn reshape(event: &Event) -> TimelineItem {
}
let p = &event.payload;
let repo_name = p.get("repo").and_then(|r| r.get("name")).and_then(Value::as_str);
let repo_name = p
.get("repo")
.and_then(|r| r.get("name"))
.and_then(Value::as_str);
let actor_login = p
.get("actor")
.and_then(|a| a.get("display_login").or_else(|| a.get("login")))
@@ -192,8 +195,14 @@ fn pull_request(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
.and_then(Value::as_str)
.unwrap_or("touched");
let pr = p.and_then(|v| v.get("pull_request"));
let number = p.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
let pr_title = pr.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or("");
let number = p
.and_then(|v| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let pr_title = pr
.and_then(|v| v.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let merged = pr
.and_then(|v| v.get("merged"))
.and_then(Value::as_bool)
@@ -224,8 +233,14 @@ fn pull_request(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn pull_request_review(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let pr = p.and_then(|v| v.get("pull_request"));
let number = pr.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
let pr_title = pr.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or("");
let number = pr
.and_then(|v| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let pr_title = pr
.and_then(|v| v.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let state = p
.and_then(|v| v.get("review"))
.and_then(|r| r.get("state"))
@@ -245,8 +260,14 @@ fn pull_request_review(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn pull_request_review_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let pr = p.and_then(|v| v.get("pull_request"));
let number = pr.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
let pr_title = pr.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or("");
let number = pr
.and_then(|v| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let pr_title = pr
.and_then(|v| v.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let body_text = p
.and_then(|v| v.get("comment"))
.and_then(|c| c.get("body"))
@@ -273,8 +294,14 @@ fn issues(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
.and_then(Value::as_str)
.unwrap_or("touched");
let issue = p.and_then(|v| v.get("issue"));
let number = issue.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
let issue_title = issue.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or("");
let number = issue
.and_then(|v| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let issue_title = issue
.and_then(|v| v.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let title = vec![
TitleSegment::text(format!("{action} issue ")),
@@ -282,15 +309,22 @@ fn issues(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
TitleSegment::text(" in "),
repo_link(repo),
];
let subtitle = (!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
let subtitle =
(!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
(TimelineIcon::Issue, title, subtitle, None)
}
fn issue_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let issue = p.and_then(|v| v.get("issue"));
let number = issue.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
let issue_title = issue.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or("");
let number = issue
.and_then(|v| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let issue_title = issue
.and_then(|v| v.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let body_text = p
.and_then(|v| v.get("comment"))
.and_then(|c| c.get("body"))
@@ -303,7 +337,8 @@ fn issue_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
TitleSegment::text(" in "),
repo_link(repo),
];
let subtitle = (!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
let subtitle =
(!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
let body = (!body_text.is_empty()).then(|| TimelineBody::Markdown {
text: body_text.to_string(),
});
@@ -312,7 +347,10 @@ fn issue_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn create(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let ref_type = p.and_then(|v| v.get("ref_type")).and_then(Value::as_str).unwrap_or("ref");
let ref_type = p
.and_then(|v| v.get("ref_type"))
.and_then(Value::as_str)
.unwrap_or("ref");
let ref_name = p.and_then(|v| v.get("ref")).and_then(Value::as_str);
let mut title = vec![TitleSegment::text(format!("created {ref_type} "))];
@@ -327,8 +365,14 @@ fn create(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn delete(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let ref_type = p.and_then(|v| v.get("ref_type")).and_then(Value::as_str).unwrap_or("ref");
let ref_name = p.and_then(|v| v.get("ref")).and_then(Value::as_str).unwrap_or("");
let ref_type = p
.and_then(|v| v.get("ref_type"))
.and_then(Value::as_str)
.unwrap_or("ref");
let ref_name = p
.and_then(|v| v.get("ref"))
.and_then(Value::as_str)
.unwrap_or("");
let title = vec![
TitleSegment::text(format!("deleted {ref_type} {ref_name} in ")),
@@ -340,7 +384,9 @@ fn delete(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn fork(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let forkee = p.and_then(|v| v.get("forkee"));
let forkee_full = forkee.and_then(|f| f.get("full_name")).and_then(Value::as_str);
let forkee_full = forkee
.and_then(|f| f.get("full_name"))
.and_then(Value::as_str);
let mut title = vec![TitleSegment::text("forked "), repo_link(repo)];
if let Some(full) = forkee_full {
@@ -366,7 +412,9 @@ fn release(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
.and_then(|r| r.get("name").or_else(|| r.get("tag_name")))
.and_then(Value::as_str)
.unwrap_or("(release)");
let url = release.and_then(|r| r.get("html_url")).and_then(Value::as_str);
let url = release
.and_then(|r| r.get("html_url"))
.and_then(Value::as_str);
let label = if let Some(u) = url {
TitleSegment::link(name.to_string(), u.to_string())
@@ -389,7 +437,10 @@ fn commit_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
.and_then(|c| c.get("body"))
.and_then(Value::as_str)
.unwrap_or("");
let title = vec![TitleSegment::text("commented on a commit in "), repo_link(repo)];
let title = vec![
TitleSegment::text("commented on a commit in "),
repo_link(repo),
];
let body = (!body_text.is_empty()).then(|| TimelineBody::Markdown {
text: body_text.to_string(),
});
@@ -398,7 +449,11 @@ fn commit_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn public(repo: Option<&str>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let title = vec![TitleSegment::text("made "), repo_link(repo), TitleSegment::text(" public")];
let title = vec![
TitleSegment::text("made "),
repo_link(repo),
TitleSegment::text(" public"),
];
(TimelineIcon::Generic, title, None, None)
}
@@ -444,11 +499,15 @@ fn search_reshape(event: &Event) -> TimelineItem {
title.push(TitleSegment::text(" "));
}
title.push(TitleSegment::text(format!("{verb} {kind} ")));
title.push(TitleSegment::link(format!("#{number}"), html_url.to_string()));
title.push(TitleSegment::link(
format!("#{number}"),
html_url.to_string(),
));
title.push(TitleSegment::text(" in "));
title.push(repo_link(&repo));
let subtitle = (!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
let subtitle =
(!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
TimelineItem {
id: event.id.clone(),
@@ -500,8 +559,8 @@ fn commit_reshape(event: &Event) -> TimelineItem {
title.push(TitleSegment::text(" in "));
title.push(repo_link(repo));
let subtitle = (!message_first_line.is_empty())
.then(|| vec![TitleSegment::text(message_first_line)]);
let subtitle =
(!message_first_line.is_empty()).then(|| vec![TitleSegment::text(message_first_line)]);
TimelineItem {
id: event.id.clone(),
@@ -525,10 +584,7 @@ fn repo_from_url(url: &str) -> Option<String> {
fn fallback(repo: Option<&str>, action: &str) -> Reshaped {
let title = match repo {
Some(r) => vec![
TitleSegment::text(format!("{action} on ")),
repo_link(r),
],
Some(r) => vec![TitleSegment::text(format!("{action} on ")), repo_link(r)],
None => vec![TitleSegment::text(action.to_string())],
};
(TimelineIcon::Generic, title, None, None)
@@ -578,7 +634,10 @@ mod tests {
TitleSegment::Link { text, .. } => text.clone(),
})
.collect();
assert!(rendered.contains("pushed 2 commits to grenade/vortex:main"), "got: {rendered}");
assert!(
rendered.contains("pushed 2 commits to grenade/vortex:main"),
"got: {rendered}"
);
match item.body.unwrap() {
TimelineBody::Commits { commits } => {
assert_eq!(commits.len(), 2);
@@ -640,7 +699,10 @@ mod tests {
let item = reshape(&ev("PushEvent", raw));
assert_eq!(item.icon, TimelineIcon::GitPush);
let r = render(&item);
assert!(r.contains("force-pushed 1 commit to grenade/x:main"), "got: {r}");
assert!(
r.contains("force-pushed 1 commit to grenade/x:main"),
"got: {r}"
);
}
#[test]
@@ -784,11 +846,16 @@ mod tests {
TitleSegment::Link { text, .. } => text.clone(),
})
.collect();
assert!(rendered.contains("committed a6fcefb in faith1337z/Trade"), "got: {rendered}");
assert!(
rendered.contains("committed a6fcefb in faith1337z/Trade"),
"got: {rendered}"
);
// body of the commit message is dropped; only first line in subtitle
assert_eq!(
item.subtitle.unwrap(),
vec![TitleSegment::text("split multiline message into multiple irc messages")]
vec![TitleSegment::text(
"split multiline message into multiple irc messages"
)]
);
}

View File

@@ -23,10 +23,7 @@ pub(crate) fn reshape(event: &Event) -> TimelineItem {
.next()
.unwrap_or("")
.to_string();
let author = p
.get("author")
.and_then(Value::as_str)
.map(author_name);
let author = p.get("author").and_then(Value::as_str).map(author_name);
let mut title = Vec::new();
if let Some(name) = author {