manager: Add first version of the techtree manager
The techtree manager is a CI tool that derives the techtree graph from the forgejo issues in this repository. It then adds graph visualizations to each issue and the wiki, to give an overview of the tree.
This commit is contained in:
parent
8ab73f4a4a
commit
870e54e263
9 changed files with 2768 additions and 0 deletions
343
techtree-manager/src/tree.rs
Normal file
343
techtree-manager/src/tree.rs
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use petgraph::visit::EdgeRef as _;
|
||||
|
||||
/// Element in the techtree
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct Element {
|
||||
/// Issue associated with this element
|
||||
pub issue_number: u64,
|
||||
/// Description of this element
|
||||
pub description: String,
|
||||
/// Type of this element
|
||||
pub ty: ElementType,
|
||||
/// Completion status of this element.
|
||||
pub status: ElementStatus,
|
||||
}
|
||||
|
||||
impl Element {
|
||||
fn to_dot_node_attributes(&self, role: Option<SubtreeElementRole>) -> String {
|
||||
let Element {
|
||||
issue_number,
|
||||
description,
|
||||
ty,
|
||||
status,
|
||||
} = self;
|
||||
|
||||
let mut attributes = Vec::new();
|
||||
|
||||
attributes.push(format!(
|
||||
r##"label = "{{{{#{issue_number} | {status}}}|{ty}|{description}}}""##
|
||||
));
|
||||
attributes.push(r#"shape = "record""#.to_owned());
|
||||
|
||||
let color = match (role, status) {
|
||||
(Some(SubtreeElementRole::ElementOfInterest), _) => "black",
|
||||
(Some(SubtreeElementRole::Dependant), _) => "gray",
|
||||
(_, ElementStatus::Missing) => "darkred",
|
||||
(_, ElementStatus::Assigned) => "orange",
|
||||
(_, ElementStatus::Completed) => "darkgreen",
|
||||
};
|
||||
attributes.push(format!(r#"color = "{color}""#));
|
||||
attributes.push(format!(r#"fontcolor = "{color}""#));
|
||||
|
||||
attributes.join(", ")
|
||||
}
|
||||
|
||||
fn to_mermaid_node(&self, index: ElementIndex, role: Option<SubtreeElementRole>) -> String {
|
||||
let Element {
|
||||
issue_number,
|
||||
description,
|
||||
ty,
|
||||
status,
|
||||
} = self;
|
||||
|
||||
let label = format!(r##"<a href='/rahix/techtree-poc/issues/{issue_number}' target='_blank'>#{issue_number}</a> | {status}\n<i>{ty}</i>\n<b>{description}</b>"##);
|
||||
|
||||
let class = match (role, status) {
|
||||
(Some(SubtreeElementRole::ElementOfInterest), _) => "eoi",
|
||||
(Some(SubtreeElementRole::Dependant), _) => "dependant",
|
||||
(_, ElementStatus::Missing) => "dep_missing",
|
||||
(_, ElementStatus::Assigned) => "dep_assigned",
|
||||
(_, ElementStatus::Completed) => "dep_completed",
|
||||
};
|
||||
|
||||
format!(
|
||||
" {index}:::{class}\n {index}[\"{label}\"]",
|
||||
index = index.index(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn mermaid_classes() -> String {
|
||||
r##"
|
||||
classDef eoi fill:#fff, stroke:#000;
|
||||
classDef dependant fill:#fff, stroke:#888, color:#888;
|
||||
classDef dep_missing fill:#fcc, stroke:#800;
|
||||
classDef dep_assigned fill:#ffa, stroke:#a50;
|
||||
classDef dep_completed fill:#afa, stroke:#080;
|
||||
"##
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum ElementType {
|
||||
Equipment,
|
||||
Process,
|
||||
Knowledge,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ElementType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
ElementType::Equipment => "Equipment",
|
||||
ElementType::Process => "Process",
|
||||
ElementType::Knowledge => "Knowledge",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum ElementStatus {
|
||||
Missing,
|
||||
Assigned,
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ElementStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
ElementStatus::Missing => "MISSING",
|
||||
ElementStatus::Assigned => "ASSIGNED",
|
||||
ElementStatus::Completed => "COMPLETED",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub type ElementIndex = petgraph::graph::NodeIndex;
|
||||
|
||||
pub struct Tree {
|
||||
graph: petgraph::Graph<Element, ()>,
|
||||
issue_map: BTreeMap<u64, ElementIndex>,
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
graph: petgraph::Graph::new(),
|
||||
issue_map: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_element(&mut self, element: Element) {
|
||||
let issue_number = element.issue_number;
|
||||
assert!(!self.issue_map.contains_key(&issue_number));
|
||||
let idx = self.graph.add_node(element);
|
||||
self.issue_map.insert(issue_number, idx);
|
||||
}
|
||||
|
||||
pub fn add_dependency_by_issue_number(&mut self, dependant: u64, dependency: u64) {
|
||||
let a = self.find_element_by_issue_number(dependant).unwrap();
|
||||
let b = self.find_element_by_issue_number(dependency).unwrap();
|
||||
self.graph.add_edge(a, b, ());
|
||||
}
|
||||
|
||||
pub fn find_element_by_issue_number(&self, issue_number: u64) -> Option<ElementIndex> {
|
||||
self.issue_map.get(&issue_number).copied()
|
||||
}
|
||||
|
||||
pub fn subtree_for_issue<'a>(&'a self, issue_number: u64) -> Option<Subtree<'a>> {
|
||||
Some(Subtree::new_for_element(
|
||||
self,
|
||||
self.find_element_by_issue_number(issue_number)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn iter_issues<'a>(&'a self) -> impl Iterator<Item = u64> + 'a {
|
||||
self.issue_map.keys().copied()
|
||||
}
|
||||
|
||||
pub fn subtree_for_element<'a>(&'a self, element: ElementIndex) -> Subtree<'a> {
|
||||
Subtree::new_for_element(self, element)
|
||||
}
|
||||
|
||||
pub fn to_dot(&self) -> String {
|
||||
let dot = petgraph::dot::Dot::with_attr_getters(
|
||||
&self.graph,
|
||||
&[
|
||||
petgraph::dot::Config::EdgeNoLabel,
|
||||
petgraph::dot::Config::NodeNoLabel,
|
||||
petgraph::dot::Config::RankDir(petgraph::dot::RankDir::BT),
|
||||
],
|
||||
&|_g, _edge_id| "".to_string(),
|
||||
&|_g, (_, element)| element.to_dot_node_attributes(None),
|
||||
);
|
||||
|
||||
format!("{:?}", dot)
|
||||
}
|
||||
|
||||
pub fn to_mermaid(&self) -> String {
|
||||
let mut mermaid = String::new();
|
||||
mermaid.push_str("flowchart BT\n");
|
||||
mermaid.push_str(&mermaid_classes());
|
||||
|
||||
for index in self.graph.node_indices() {
|
||||
mermaid.push_str(&self.graph[index].to_mermaid_node(index, None));
|
||||
mermaid.push_str("\n");
|
||||
}
|
||||
|
||||
for edge in self.graph.edge_references() {
|
||||
mermaid.push_str(&format!(
|
||||
" {} --> {}\n",
|
||||
edge.source().index(),
|
||||
edge.target().index()
|
||||
));
|
||||
}
|
||||
|
||||
mermaid
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct SubtreeElement<'a> {
|
||||
element: &'a Element,
|
||||
role: SubtreeElementRole,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum SubtreeElementRole {
|
||||
ElementOfInterest,
|
||||
Dependency,
|
||||
Dependant,
|
||||
}
|
||||
|
||||
pub struct Subtree<'a> {
|
||||
original: &'a Tree,
|
||||
graph: petgraph::Graph<SubtreeElement<'a>, ()>,
|
||||
index_map: BTreeMap<ElementIndex, ElementIndex>,
|
||||
}
|
||||
|
||||
impl<'a> Subtree<'a> {
|
||||
pub fn new_for_element(tree: &'a Tree, element: ElementIndex) -> Subtree<'a> {
|
||||
let mut this = Self {
|
||||
original: tree,
|
||||
graph: petgraph::Graph::new(),
|
||||
index_map: BTreeMap::new(),
|
||||
};
|
||||
let graph = &this.original.graph;
|
||||
|
||||
this.add_node_if_missing(element, SubtreeElementRole::ElementOfInterest);
|
||||
|
||||
let mut dfs = petgraph::visit::Dfs::new(&graph, element);
|
||||
while let Some(idx) = dfs.next(&graph) {
|
||||
this.add_node_if_missing(idx, SubtreeElementRole::Dependency);
|
||||
}
|
||||
|
||||
for idx in this.index_map.keys().copied() {
|
||||
for neighbor in graph.neighbors_directed(idx, petgraph::Direction::Outgoing) {
|
||||
if !this.index_map.contains_key(&neighbor) {
|
||||
log::warn!(
|
||||
"Probably missed a dependency from #{from} to #{to} while generating subtree for #{el}",
|
||||
el = graph[element].issue_number,
|
||||
from = graph[idx].issue_number,
|
||||
to = graph[neighbor].issue_number
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.graph
|
||||
.add_edge(this.index_map[&idx], this.index_map[&neighbor], ());
|
||||
}
|
||||
}
|
||||
|
||||
for neighbor in graph.neighbors_directed(element, petgraph::Direction::Incoming) {
|
||||
if this.index_map.contains_key(&neighbor) {
|
||||
log::warn!(
|
||||
"Already found #{from} in dependencies of #{to}, but it should have been the other way around?!",
|
||||
from = graph[neighbor].issue_number,
|
||||
to = graph[element].issue_number
|
||||
);
|
||||
} else {
|
||||
this.add_node_if_missing(neighbor, SubtreeElementRole::Dependant);
|
||||
}
|
||||
|
||||
this.graph
|
||||
.add_edge(this.index_map[&neighbor], this.index_map[&element], ());
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
fn add_node_if_missing(&mut self, index: ElementIndex, role: SubtreeElementRole) -> bool {
|
||||
if self.index_map.contains_key(&index) {
|
||||
debug_assert!(self.graph.node_weight(self.index_map[&index]).is_some());
|
||||
return false;
|
||||
}
|
||||
|
||||
let subtree_element = SubtreeElement {
|
||||
element: &self.original.graph[index],
|
||||
role,
|
||||
};
|
||||
|
||||
let new_idx = self.graph.add_node(subtree_element);
|
||||
self.index_map.insert(index, new_idx);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn to_dot(&self) -> String {
|
||||
let dot = petgraph::dot::Dot::with_attr_getters(
|
||||
&self.graph,
|
||||
&[
|
||||
petgraph::dot::Config::EdgeNoLabel,
|
||||
petgraph::dot::Config::NodeNoLabel,
|
||||
petgraph::dot::Config::RankDir(petgraph::dot::RankDir::BT),
|
||||
],
|
||||
&|_g, _edge_id| "".to_string(),
|
||||
&|_g, (_, element)| element.element.to_dot_node_attributes(Some(element.role)),
|
||||
);
|
||||
|
||||
format!("{:?}", dot)
|
||||
}
|
||||
|
||||
pub fn to_mermaid(&self) -> String {
|
||||
let mut mermaid = String::new();
|
||||
mermaid.push_str("flowchart BT\n");
|
||||
mermaid.push_str(&mermaid_classes());
|
||||
|
||||
for index in self.graph.node_indices() {
|
||||
let node = &self.graph[index];
|
||||
mermaid.push_str(&node.element.to_mermaid_node(index, Some(node.role)));
|
||||
mermaid.push_str("\n");
|
||||
}
|
||||
|
||||
for edge in self.graph.edge_references() {
|
||||
mermaid.push_str(&format!(
|
||||
" {} --> {}\n",
|
||||
edge.source().index(),
|
||||
edge.target().index()
|
||||
));
|
||||
}
|
||||
|
||||
mermaid
|
||||
}
|
||||
|
||||
pub fn stable_hash(&self) -> String {
|
||||
let mut nodes: Vec<_> = self.graph.node_weights().collect();
|
||||
nodes.sort_by_key(|n| n.element.issue_number);
|
||||
let mut edges: Vec<_> = self
|
||||
.graph
|
||||
.edge_references()
|
||||
.map(|edge| {
|
||||
(
|
||||
self.graph[edge.source()].element.issue_number,
|
||||
self.graph[edge.target()].element.issue_number,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
edges.sort();
|
||||
|
||||
let json_data = serde_json::ser::to_string(&(nodes, edges)).unwrap();
|
||||
sha256::digest(json_data)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue