Compare commits

...

3 commits

Author SHA1 Message Date
81ce471d0e Only place real "Ultimate Goal" elements at the bottom of the graph
All checks were successful
/ build (push) Successful in 1m12s
"Ultimate Goal" elements are only counted as such if they fulfil the
following requirements:

- They have the "Ultimate Goal" issue label
- They are not depended on by any other elements
2026-01-23 14:55:54 +01:00
8b0aef0599 Treat elements with "Ultimate Goal" label specially
Mainly restyle them to appear different from other techtree elements.
Also ignore any element status for these, because it is meaningless
(they can never be reached).
2026-01-23 14:48:49 +01:00
be7f305a6b Fix clippy lints 2025-12-09 16:49:07 +01:00
6 changed files with 53 additions and 31 deletions

View file

@ -24,7 +24,7 @@ pub async fn collect_tree(ctx: &crate::Context) -> anyhow::Result<crate::tree::T
.await .await
.with_context(|| format!("Failed fetching page {page} of the issue list"))?; .with_context(|| format!("Failed fetching page {page} of the issue list"))?;
if new.len() == 0 { if new.is_empty() {
break; break;
} }
@ -84,7 +84,7 @@ pub async fn collect_tree(ctx: &crate::Context) -> anyhow::Result<crate::tree::T
} }
let dep_number = dep.number.context("Missing issue number in dependency")?; let dep_number = dep.number.context("Missing issue number in dependency")?;
if !tree.find_element_by_issue_number(dep_number).is_some() { if tree.find_element_by_issue_number(dep_number).is_none() {
log::warn!("Found dependency from #{issue} on non-tracked issue #{dep_number}!"); log::warn!("Found dependency from #{issue} on non-tracked issue #{dep_number}!");
} else { } else {
tree.add_dependency_by_issue_number(issue, dep_number); tree.add_dependency_by_issue_number(issue, dep_number);
@ -128,16 +128,18 @@ fn element_from_issue(issue: &forgejo_api::structs::Issue) -> anyhow::Result<cra
.iter() .iter()
.any(|l| l.name.as_deref() == Some("Completed")); .any(|l| l.name.as_deref() == Some("Completed"));
let has_ultimate_label = labels
.iter()
.any(|l| l.name.as_deref() == Some("Ultimate Goal"));
let status = match issue.state.context("Missing issue state")? { let status = match issue.state.context("Missing issue state")? {
forgejo_api::structs::StateType::Open => { forgejo_api::structs::StateType::Open => {
if has_completed_label { if has_ultimate_label {
crate::tree::ElementStatus::UltimateGoal
} else if has_completed_label {
crate::tree::ElementStatus::Completed crate::tree::ElementStatus::Completed
} else if issue.assignee.is_some() } else if issue.assignee.is_some()
|| issue || issue.assignees.as_ref().is_some_and(|v| !v.is_empty())
.assignees
.as_ref()
.map(|v| v.len() > 0)
.unwrap_or(false)
{ {
crate::tree::ElementStatus::Assigned crate::tree::ElementStatus::Assigned
} else { } else {

View file

@ -57,14 +57,14 @@ pub async fn find_bot_comment(
.rev() .rev()
.find(|comment| comment.user.as_ref().and_then(|u| u.id) == Some(-2)); .find(|comment| comment.user.as_ref().and_then(|u| u.id) == Some(-2));
Ok(maybe_bot_comment maybe_bot_comment
.map(|c| -> anyhow::Result<_> { .map(|c| -> anyhow::Result<_> {
Ok(BotCommentInfo { Ok(BotCommentInfo {
body: c.body.clone().unwrap_or("".to_owned()), body: c.body.clone().unwrap_or("".to_owned()),
id: c.id.context("Missing id for the bot comment")?, id: c.id.context("Missing id for the bot comment")?,
}) })
}) })
.transpose()?) .transpose()
} }
/// Find existing bot comment or create a new one. /// Find existing bot comment or create a new one.
@ -115,12 +115,12 @@ pub async fn update_bot_comment_from_subtree(
subtree: &crate::tree::Subtree<'_>, subtree: &crate::tree::Subtree<'_>,
hash: &str, hash: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut mermaid = subtree.to_mermaid(&ctx.repo_url.to_string(), false); let mut mermaid = subtree.to_mermaid(ctx.repo_url.as_ref(), false);
// When the mermaid graph gets too big, regenerate a simplified version. // When the mermaid graph gets too big, regenerate a simplified version.
if mermaid.len() > 4990 { if mermaid.len() > 4990 {
log::info!("Mermaid graph is too big, generating simplified version..."); log::info!("Mermaid graph is too big, generating simplified version...");
mermaid = subtree.to_mermaid(&ctx.repo_url.to_string(), true); mermaid = subtree.to_mermaid(ctx.repo_url.as_ref(), true);
} }
let full_text = if mermaid.len() > 4990 { let full_text = if mermaid.len() > 4990 {
@ -143,7 +143,7 @@ _Sorry, the partial techtree is still too big to be displayed for this element..
) )
}; };
update_bot_comment(&ctx, id, full_text).await?; update_bot_comment(ctx, id, full_text).await?;
Ok(()) Ok(())
} }
@ -157,8 +157,7 @@ pub async fn remove_stale_label(ctx: &crate::Context, issue_number: u64) -> anyh
let stale_label_id = labels let stale_label_id = labels
.iter() .iter()
.filter(|l| l.name.as_deref() == Some("Stale")) .find(|l| l.name.as_deref() == Some("Stale"))
.next()
.map(|l| l.id.ok_or(anyhow::anyhow!("`Stale` label has no ID"))) .map(|l| l.id.ok_or(anyhow::anyhow!("`Stale` label has no ID")))
.transpose()?; .transpose()?;

View file

@ -1,4 +1,7 @@
#![allow(dead_code)] #![allow(dead_code)]
#![allow(clippy::useless_format)]
#![allow(clippy::needless_return)]
#![deny(clippy::as_conversions)]
use anyhow::Context as _; use anyhow::Context as _;
use forgejo_api::Forgejo; use forgejo_api::Forgejo;
@ -69,7 +72,7 @@ async fn run() -> anyhow::Result<()> {
.map(|token| -> Result<_, ()> { .map(|token| -> Result<_, ()> {
let mut repo_auth_url = repo_url.clone(); let mut repo_auth_url = repo_url.clone();
repo_auth_url.set_username("forgejo-actions")?; repo_auth_url.set_username("forgejo-actions")?;
repo_auth_url.set_password(Some(&token))?; repo_auth_url.set_password(Some(token))?;
Ok(repo_auth_url) Ok(repo_auth_url)
}) })
.transpose() .transpose()
@ -145,7 +148,9 @@ async fn run() -> anyhow::Result<()> {
} }
'issues: for issue in tree.iter_issues() { 'issues: for issue in tree.iter_issues() {
let subtree = tree.subtree_for_issue(issue).expect("issue from tree not found in tree"); let subtree = tree
.subtree_for_issue(issue)
.expect("issue from tree not found in tree");
let hash = subtree.stable_hash(); let hash = subtree.stable_hash();
let (bot_comment, _is_new) = issue::find_or_make_bot_comment(&ctx, issue) let (bot_comment, _is_new) = issue::find_or_make_bot_comment(&ctx, issue)

View file

@ -52,14 +52,14 @@ pub async fn render(
std::fs::create_dir(&info.repo_dir).context("Failed creating directory for rendered graph")?; std::fs::create_dir(&info.repo_dir).context("Failed creating directory for rendered graph")?;
let dot_source = tree.to_dot(); let dot_source = tree.to_dot();
std::fs::write(&info.dot_file(), dot_source.as_bytes()) std::fs::write(info.dot_file(), dot_source.as_bytes())
.context("Failed to write `dot` source file")?; .context("Failed to write `dot` source file")?;
Command::new("dot") Command::new("dot")
.args(["-T", "svg"]) .args(["-T", "svg"])
.arg("-o") .arg("-o")
.arg(&info.svg_file()) .arg(info.svg_file())
.arg(&info.dot_file()) .arg(info.dot_file())
.success() .success()
.context("Failed to generate svg graph from dot source")?; .context("Failed to generate svg graph from dot source")?;

View file

@ -55,6 +55,7 @@ impl Element {
let (color, background) = match (subtree_role, status) { let (color, background) = match (subtree_role, status) {
(Some(SubtreeElementRole::ElementOfInterest), _) => ("black", "white"), (Some(SubtreeElementRole::ElementOfInterest), _) => ("black", "white"),
(Some(SubtreeElementRole::Dependant), _) => ("gray", "gray"), (Some(SubtreeElementRole::Dependant), _) => ("gray", "gray"),
(_, ElementStatus::UltimateGoal) => ("black", "white"),
(_, ElementStatus::Missing) => ( (_, ElementStatus::Missing) => (
"#800", "#800",
// Highlight root elements // Highlight root elements
@ -100,6 +101,7 @@ impl Element {
let class = match (role, status) { let class = match (role, status) {
(Some(SubtreeElementRole::ElementOfInterest), _) => "eoi", (Some(SubtreeElementRole::ElementOfInterest), _) => "eoi",
(Some(SubtreeElementRole::Dependant), _) => "dependant", (Some(SubtreeElementRole::Dependant), _) => "dependant",
(_, ElementStatus::UltimateGoal) => "ultimate",
(_, ElementStatus::Missing) => "dep_missing", (_, ElementStatus::Missing) => "dep_missing",
(_, ElementStatus::Assigned) => "dep_assigned", (_, ElementStatus::Assigned) => "dep_assigned",
(_, ElementStatus::Completed) => "dep_completed", (_, ElementStatus::Completed) => "dep_completed",
@ -116,6 +118,7 @@ fn mermaid_classes() -> String {
r##" r##"
classDef eoi fill:#fff, stroke:#000, color:#000; classDef eoi fill:#fff, stroke:#000, color:#000;
classDef dependant fill:#fff, stroke:#888, color:#888; classDef dependant fill:#fff, stroke:#888, color:#888;
classDef ultimate fill:#fff, stroke:#000, color:#000;
classDef dep_missing fill:#fcc, stroke:#800, color:#000; classDef dep_missing fill:#fcc, stroke:#800, color:#000;
classDef dep_assigned fill:#ffa, stroke:#a50, color:#000; classDef dep_assigned fill:#ffa, stroke:#a50, color:#000;
classDef dep_completed fill:#afa, stroke:#080, color:#000; classDef dep_completed fill:#afa, stroke:#080, color:#000;
@ -128,6 +131,7 @@ pub enum ElementStatus {
Missing, Missing,
Assigned, Assigned,
Completed, Completed,
UltimateGoal,
} }
impl std::fmt::Display for ElementStatus { impl std::fmt::Display for ElementStatus {
@ -136,6 +140,7 @@ impl std::fmt::Display for ElementStatus {
ElementStatus::Missing => "MISSING", ElementStatus::Missing => "MISSING",
ElementStatus::Assigned => "ASSIGNED", ElementStatus::Assigned => "ASSIGNED",
ElementStatus::Completed => "COMPLETED", ElementStatus::Completed => "COMPLETED",
ElementStatus::UltimateGoal => "ULTIMATE GOAL",
}) })
} }
} }
@ -194,12 +199,23 @@ impl Tree {
} }
pub fn iter_ultimate_elements<'a>(&'a self) -> impl Iterator<Item = ElementIndex> + 'a { pub fn iter_ultimate_elements<'a>(&'a self) -> impl Iterator<Item = ElementIndex> + 'a {
self.graph.node_indices().filter(|index| {
// If there are no incoming edges, then this is an ultimate element!
self.graph self.graph
.node_indices()
.filter(|index| {
self.graph.node_weight(*index).unwrap().status == ElementStatus::UltimateGoal
})
.filter(|index| {
// Ultimate goal elements must not have any incoming dependency edges. Warn and
// ignore it, if one does.
let has_no_incoming = self.graph
.neighbors_directed(*index, petgraph::Direction::Incoming) .neighbors_directed(*index, petgraph::Direction::Incoming)
.next() .next()
.is_none() .is_none();
if !has_no_incoming {
let el = self.graph.node_weight(*index).unwrap();
log::warn!("Element #{} is marked \"Ultimate Goal\" but is depended on by others? Ignoring...", el.issue_number);
}
has_no_incoming
}) })
} }
@ -263,7 +279,7 @@ impl Tree {
for index in self.graph.node_indices() { for index in self.graph.node_indices() {
mermaid.push_str(&self.graph[index].to_mermaid_node(index, None, repo_url, simple)); mermaid.push_str(&self.graph[index].to_mermaid_node(index, None, repo_url, simple));
mermaid.push_str("\n"); mermaid.push('\n');
} }
for edge in self.graph.edge_references() { for edge in self.graph.edge_references() {
@ -397,7 +413,7 @@ impl<'a> Subtree<'a> {
repo_url, repo_url,
simple, simple,
)); ));
mermaid.push_str("\n"); mermaid.push('\n');
} }
for edge in self.graph.edge_references() { for edge in self.graph.edge_references() {

View file

@ -5,7 +5,7 @@ pub async fn update_wiki_from_tree(
ctx: &crate::Context, ctx: &crate::Context,
tree: &crate::tree::Tree, tree: &crate::tree::Tree,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mermaid = tree.to_mermaid(&ctx.repo_url.to_string(), false); let mermaid = tree.to_mermaid(ctx.repo_url.as_ref(), false);
let wiki_text = format!( let wiki_text = format!(
r##"This page is automatically updated to show the latest and greatest FAFO techtree: r##"This page is automatically updated to show the latest and greatest FAFO techtree:
@ -14,7 +14,7 @@ pub async fn update_wiki_from_tree(
``` ```
"## "##
); );
update_wiki_overview(&ctx, wiki_text) update_wiki_overview(ctx, wiki_text)
.await .await
.context("Failed to update the techtree wiki page")?; .context("Failed to update the techtree wiki page")?;
Ok(()) Ok(())