Compare commits

...

No commits in common. "render" and "main" have entirely different histories.
render ... main

18 changed files with 3472 additions and 1726 deletions

View file

@ -0,0 +1,14 @@
on:
issues:
types: [opened, reopened, closed, labeled, edited]
jobs:
test:
runs-on: self-hosted-nixos-x86_64
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Check techtree-manager presence
run: |
test -e ~/.cache/fafo-techtree/techtree-manager
- name: Run techtree-manager
run: cd techtree-manager/ && nix-shell shell.nix --run ~/.cache/fafo-techtree/techtree-manager

View file

@ -0,0 +1,17 @@
on:
push:
branches:
- 'main'
- 'poc'
jobs:
build:
runs-on: self-hosted-nixos-x86_64
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Build techtree manager tool
run: cd techtree-manager/ && nix-shell shell.nix --run "cargo build --release"
- name: Cache the techtree manager tool
run: |
mkdir -p ~/.cache/fafo-techtree
cp -v techtree-manager/target/release/techtree-manager ~/.cache/fafo-techtree/techtree-manager

140
README.md Normal file
View file

@ -0,0 +1,140 @@
FAFO Technology Tree
====================
This repository tracks our "technology tree" — the steps towards our
overarching goals.
A description of how this tech tree works can be found below. See
[Working with the Tech Tree](#working-with-the-tech-tree).
## Full Technology Tree
Below, you can see the full technology tree. The issues in this repository
track the tree's elements. For a better overview, check each issue for a
filtered subtree that contains just the elements related to it.
![FAFO Tech Tree](https://git.fa-fo.de/fafo/techtree/media/branch/render/techtree.svg)
## Working with the Tech Tree
Fundamentally, the tech tree is built from the issues in this repository and
their interdependencies. For elements to be reached, create new issues and
select one of the `Type/###` labels to declare what kind it is:
- Type/**Equipment** 🔬 — A piece of machinery that we need to acquire or get
running. Mainly things that can be bought instead of built.
- Type/**Process** ⚗️ — A process we need to achieve. This means we need to
become able to perform this process reliably.
- Type/**Development** 🔩 — A device, software, or other thing we need to develop.
This is _engineering_ work; making use of existing knowledge to build
something useful.
- Type/**Research** 🧪 — A topic that we can or must research to unlock future
capabilities. In contrast to _Development_ elements, we cannot make use of
prior art here. So less engineering and more _science_.
#### Dependencies
Once created, add dependencies between issues to model their relationships.
The CI of this repository will automatically update the tech tree accordingly.
In addition, each issue gets a partial representation of the subtree of
elements directly related to it. You can quickly see what is still missing to
achieve a particular element. And also what next steps will be unlocked once
an element has been achieved.
There are no distinct types of dependencies. This was a choice in the name of
keeping the model simple. All dependencies are hard requirements. But also
check the next section on more thoughts about this...
#### Modelling Approach
Finding the right balance between model complexity and expressiveness is
tricky. Following are some guidelines for adding elements to this tech tree.
All elements should have a few fundamental properties, to keep the model consistent:
- Elements shall have a **clear and unambiguous acceptance criterion**. If
necessary, this can be elaborated on in the issue body. "SEM Imaging" leaves
open what scale we can image reliably. It may be sensible to add multiple
elements to model progress in such a domain.
- Elements shall be **actionable**, in the sense that someone can put in effort to
achieve them. Having a good acceptance criterion does most of the heavy
lifting here.
- Elements shall be **necessary** for our bigger visions. Ultimate elements
(elements that nothing depends on) should get special consideration in this
regard. If you have visions of your own, it is of course fine to make an
ultimate element for them.
- Elements shall have a scope/granularity size that is appropriate for tracking
progress. We will need to figure this out as we go.
Generally, our tech tree is a living object. It should be updated as we figure
out more elements to be tracked and their dependencies.
One topic of particular interest are "path choices". We can either use
technology A or technology B to achieve element C:
```mermaid
flowchart BT
classDef eoi fill:#fff, stroke:#000, color:#000;
classDef dependant fill:#fff, stroke:#888, color:#888;
classDef dep_missing fill:#fcc, stroke:#800, color:#000;
classDef dep_assigned fill:#ffa, stroke:#a50, color:#000;
classDef dep_completed fill:#afa, stroke:#080, color:#000;
0:::dep_missing
0["#1 | MISSING\n<i>Process</i>\n<b>Technology A</b>"]
1:::dep_completed
1["#2 | COMPLETED\n<i>Process</i>\n<b>Technology B</b>"]
2:::eoi
2["#3 | MISSING\n<i>Process</i>\n<b>Element C</b>"]
2 --> 0
2 --> 1
```
Dependencies cannot express such choices. Instead, please follow this approach:
- Initially, all potential path choice dependencies are added. The issue body
shall document the choice options and implications.
- When we get closer to the element of interest, we make the choice of what
path to pursue. At this time, the other dependencies are dropped. The
tech-tree now only documents the path choice we have made.
Another situation we will encounter is the need to use generic equipment for
specific purposes. If the specific purpose is non-trivial, it shall be
modelled as an intermediate _Process_ element in-between:
```mermaid
flowchart BT
classDef eoi fill:#fff, stroke:#000, color:#000;
classDef dependant fill:#fff, stroke:#888, color:#888;
classDef dep_missing fill:#fcc, stroke:#800, color:#000;
classDef dep_assigned fill:#ffa, stroke:#a50, color:#000;
classDef dep_completed fill:#afa, stroke:#080, color:#000;
0:::dep_missing
0["#2 | MISSING\n<i>Process</i>\n<b>Processing X using machine A</b>"]
1:::dep_completed
1["#1 | COMPLETED\n<i>Equipment</i>\n<b>Generic Machine A</b>"]
2:::eoi
2["#3 | MISSING\n<i>Process</i>\n<b>Element C</b>"]
2 --> 0
0 --> 1
```
#### Element Status
The status of each element is determined as follows:
- **MISSING** when the element has not yet been achieved.
- **ASSIGNED** when the issue has been assigned to someone. This means we are making progress!
- **COMPLETED** when the issue is labelled `Completed`. Achievement unlocked!
#### Important notes
There are a few gotchas that you should be aware of:
- The CI will not automatically update when changing dependencies,
unfortunately. You can force a trigger by editing the issue body or quickly
adding and then removing the `Stale` label.
- Issues that should no longer be a part of the techtree should either be
deleted (looses all tracking) or they can be made inert by closing them.

2
techtree-manager/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target/
/render-git/

1987
techtree-manager/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
[package]
name = "techtree-manager"
version = "0.1.0"
edition = "2024"
authors = ["rahix <rahix@rahix.de>"]
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
anyhow = "1.0.98"
base64 = "0.22.1"
chrono = "0.4.41"
env_logger = { version = "0.11.8", default-features = false, features = ["auto-color", "color", "humantime"] }
forgejo-api = { git = "https://git.fa-fo.de/rahix/forgejo-api.git", rev = "a3f6452cfe774898a89ac66be393e5205f5e12b7" }
log = "0.4.27"
petgraph = "0.8.1"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sha256 = "1.6.0"
time = "0.3.41"
tokio = { version = "1.45.0", features = ["full"] }
url = "2.5.4"

View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,23 @@
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,21 @@
# From 2025-05-21
let pkgs = import (fetchTarball("https://github.com/NixOS/nixpkgs/archive/36ecfe6216f0aa7f2a1ffe5aafc2c0eae6c8cdcf.tar.gz")) {};
in pkgs.mkShell {
buildInputs = [
# Rust
pkgs.cargo
pkgs.rustc
pkgs.rustfmt
# Dependencies
pkgs.openssl
pkgs.graphviz
pkgs.git
];
shellHook = ''
export OPENSSL_DIR="${pkgs.openssl.dev}"
export OPENSSL_LIB_DIR="${pkgs.openssl.out}/lib"
'';
}

View file

@ -0,0 +1,145 @@
use anyhow::Context as _;
/// Read all issues to generate the full techtree
pub async fn collect_tree(ctx: &crate::Context) -> anyhow::Result<crate::tree::Tree> {
let mut issues = vec![];
for page in 1.. {
let new = ctx
.forgejo
.issue_list_issues(
&ctx.owner,
&ctx.repo,
forgejo_api::structs::IssueListIssuesQuery {
// We also want the closed issues
state: Some(forgejo_api::structs::IssueListIssuesQueryState::All),
// We cannot turn off pagination entirely, but let's set the limit as high as
// Forgejo lets us.
limit: Some(10000),
page: Some(page),
// Only issues
r#type: Some(forgejo_api::structs::IssueListIssuesQueryType::Issues),
..Default::default()
},
)
.await
.with_context(|| format!("Failed fetching page {page} of the issue list"))?;
if new.len() == 0 {
break;
}
issues.extend(new);
}
let mut tree = crate::tree::Tree::new();
let mut issue_numbers = Vec::new();
for issue in issues.iter() {
let element = match element_from_issue(issue) {
Ok(el) => el,
Err(e) => {
let maybe_number = issue
.number
.map(|n| n.to_string())
.unwrap_or("<unknown>".to_owned());
log::warn!("Failed processing issue #{maybe_number}: {e:?}");
continue;
}
};
issue_numbers.push(element.issue_number);
tree.add_element(element);
}
for issue in issue_numbers.into_iter() {
let dependencies = ctx
.forgejo
.issue_list_issue_dependencies(
&ctx.owner,
&ctx.repo,
// Why the hell is the issue number a string here?
&issue.to_string(),
forgejo_api::structs::IssueListIssueDependenciesQuery {
limit: Some(10000),
..Default::default()
},
)
.await
.with_context(|| format!("Failed to fetch issue dependencies for #{issue}",))?;
for dep in dependencies {
let dep_number = dep.number.context("Missing issue number in dependency")?;
if !tree.find_element_by_issue_number(dep_number).is_some() {
log::warn!("Found dependency from #{issue} on non-tracked issue #{dep_number}!");
} else {
tree.add_dependency_by_issue_number(issue, dep_number);
}
}
}
Ok(tree)
}
fn element_from_issue(issue: &forgejo_api::structs::Issue) -> anyhow::Result<crate::tree::Element> {
let issue_number = issue.number.context("Missing issue number")?;
let description = issue
.title
.as_deref()
.context("Issue is missing a title")?
.to_owned();
let labels = issue
.labels
.as_ref()
.context("Issue does not have any labels")?;
let ty_labels: Vec<_> = labels
.iter()
.filter_map(|l| l.name.as_deref())
.filter(|l| l.starts_with("Type/"))
.collect();
if ty_labels.len() == 0 {
anyhow::bail!("Issue #{issue_number} has no type label!");
}
if ty_labels.len() > 1 {
anyhow::bail!("Issue #{issue_number} has more than one type label!");
}
let ty = ty_labels
.first()
.unwrap()
.strip_prefix("Type/")
.unwrap()
.to_owned();
let has_completed_label = labels
.iter()
.any(|l| l.name.as_deref() == Some("Completed"));
let status = match issue.state.context("Missing issue state")? {
forgejo_api::structs::StateType::Open => {
if has_completed_label {
crate::tree::ElementStatus::Completed
} else if issue.assignee.is_some()
|| issue
.assignees
.as_ref()
.map(|v| v.len() > 0)
.unwrap_or(false)
{
crate::tree::ElementStatus::Assigned
} else {
crate::tree::ElementStatus::Missing
}
}
forgejo_api::structs::StateType::Closed => anyhow::bail!("Ignoring closed issue!"),
};
Ok(crate::tree::Element {
issue_number,
description,
ty,
status,
})
}

View file

@ -0,0 +1,57 @@
use anyhow::Context as _;
#[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct IssueEventMeta {
pub action: IssueAction,
pub issue: IssueMeta,
}
#[derive(serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum IssueAction {
Opened,
Reopened,
Closed,
Assigned,
Unassigned,
Edited,
#[serde(rename = "label_updated")]
LabelUpdated,
Labeled,
#[serde(rename = "label_cleared")]
LabelCleared,
Unlabeled,
}
#[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct IssueMeta {
pub number: u64,
pub repository: RepoMeta,
}
#[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct RepoMeta {
pub name: String,
pub owner: String,
}
pub fn get_issue_event_meta_from_env() -> anyhow::Result<IssueEventMeta> {
let path = std::env::var_os("GITHUB_EVENT_PATH")
.context("Could not get event description file path (GITHUB_EVENT_PATH)")?;
let f = std::fs::File::open(path).context("Could not open GITHUB_EVENT_PATH file")?;
let meta: IssueEventMeta = serde_json::de::from_reader(f).context("Failed to parse")?;
Ok(meta)
}
pub fn fake() -> IssueEventMeta {
IssueEventMeta {
action: IssueAction::Edited,
issue: IssueMeta {
number: 1337,
repository: RepoMeta {
name: "techtree".to_owned(),
owner: "fafo".to_owned(),
},
},
}
}

View file

@ -0,0 +1,186 @@
use anyhow::Context as _;
pub type CommentId = u64;
pub struct BotCommentInfo {
pub body: String,
pub id: CommentId,
}
pub async fn make_bot_comment(
ctx: &crate::Context,
issue_number: u64,
) -> anyhow::Result<BotCommentInfo> {
let initial_message =
"_Please be patient, this issue is currently being integrated into the techtree..._";
let res = ctx
.forgejo
.issue_create_comment(
&ctx.owner,
&ctx.repo,
issue_number,
forgejo_api::structs::CreateIssueCommentOption {
body: initial_message.to_owned(),
updated_at: None,
},
)
.await?;
Ok(BotCommentInfo {
id: res.id.unwrap(),
body: initial_message.to_owned(),
})
}
pub async fn find_bot_comment(
ctx: &crate::Context,
issue_number: u64,
) -> anyhow::Result<Option<BotCommentInfo>> {
let mut comments = ctx
.forgejo
.issue_get_comments(
&ctx.owner,
&ctx.repo,
issue_number,
forgejo_api::structs::IssueGetCommentsQuery {
..Default::default()
},
)
.await
.context("Failed fetching comments for issue")?;
comments.sort_by_key(|comment| comment.created_at);
let maybe_bot_comment = comments
.iter()
.rev()
.find(|comment| comment.user.as_ref().unwrap().id == Some(-2));
Ok(maybe_bot_comment.map(|c| BotCommentInfo {
body: c.body.clone().unwrap_or("".to_owned()),
id: c.id.unwrap(),
}))
}
/// Find existing bot comment or create a new one.
///
/// Returns a tuple of the comment information and a boolean indicating whether the comment was
/// newly created.
pub async fn find_or_make_bot_comment(
ctx: &crate::Context,
issue_number: u64,
) -> anyhow::Result<(BotCommentInfo, bool)> {
if let Some(comment) = find_bot_comment(ctx, issue_number)
.await
.context("Failed to search for bot comment in issue")?
{
Ok((comment, false))
} else {
make_bot_comment(ctx, issue_number)
.await
.context("Failed to make new bot comment in issue")
.map(|c| (c, true))
}
}
pub async fn update_bot_comment(
ctx: &crate::Context,
id: CommentId,
new_body: String,
) -> anyhow::Result<()> {
ctx.forgejo
.issue_edit_comment(
&ctx.owner,
&ctx.repo,
id,
forgejo_api::structs::EditIssueCommentOption {
body: new_body,
updated_at: None,
},
)
.await
.context("Failed to update comment body")?;
Ok(())
}
pub async fn update_bot_comment_from_subtree(
ctx: &crate::Context,
id: CommentId,
subtree: &crate::tree::Subtree<'_>,
hash: &str,
) -> anyhow::Result<()> {
let mut mermaid = subtree.to_mermaid(&ctx.repo_url.to_string(), false);
// When the mermaid graph gets too big, regenerate a simplified version.
if mermaid.len() > 4990 {
log::info!("Mermaid graph is too big, generating simplified version...");
mermaid = subtree.to_mermaid(&ctx.repo_url.to_string(), true);
}
let full_text = if mermaid.len() > 4990 {
format!(
r##"## Partial Techtree
_Sorry, the partial techtree is still too big to be displayed for this element..._
<small>Digest: {hash}; Last Updated: {timestamp}</small>"##,
timestamp = ctx.timestamp,
)
} else {
format!(
r##"## Partial Techtree
```mermaid
{mermaid}
```
<small>Digest: {hash}; Last Updated: {timestamp}</small>"##,
timestamp = ctx.timestamp,
)
};
update_bot_comment(&ctx, id, full_text).await?;
Ok(())
}
pub async fn remove_stale_label(ctx: &crate::Context, issue_number: u64) -> anyhow::Result<()> {
let labels = ctx
.forgejo
.issue_get_labels(&ctx.owner, &ctx.repo, issue_number)
.await
.context("Failed fetching issue labels")?;
let stale_label_id = labels
.iter()
.filter(|l| l.name.as_deref() == Some("Stale"))
.next()
.map(|l| l.id.unwrap());
if let Some(stale_label_id) = stale_label_id {
log::info!("Removing `Stale` label from issue #{issue_number}...");
let res = ctx
.forgejo
.issue_remove_label(
&ctx.owner,
&ctx.repo,
issue_number,
stale_label_id,
forgejo_api::structs::DeleteLabelsOption {
updated_at: Some(time::OffsetDateTime::now_utc()),
},
)
.await;
if let Err(e) = res {
// At the moment, the token for Forgejo Actions cannot remove issue labels.
// See https://codeberg.org/forgejo/forgejo/issues/2415
log::warn!(
"Failed to remove `Stale` label for #{issue_number}. This is a known Forgejo limitation at the moment... ({e})"
);
}
}
Ok(())
}

View file

@ -0,0 +1,152 @@
#![allow(dead_code)]
use anyhow::Context as _;
use forgejo_api::Forgejo;
mod collect;
mod event_meta;
mod issue;
mod render;
mod tree;
mod wiki;
pub struct Context {
/// API Accessor object
pub forgejo: forgejo_api::Forgejo,
/// Repository Owner
pub owner: String,
/// Repository Name
pub repo: String,
/// URL of the repository page
pub repo_url: url::Url,
/// URL of the repository with authentication information attached
pub repo_auth_url: url::Url,
/// Human readable timestamp of this manager run
pub timestamp: String,
}
async fn run() -> anyhow::Result<()> {
let meta = if std::env::var("TECHTREE_FAKE").ok().is_some() {
log::warn!("Fake tree!");
event_meta::fake()
} else {
event_meta::get_issue_event_meta_from_env().context("Failed reading issue event data")?
};
log::info!(
"Running due to event \"{:?}\" on issue #{} ...",
meta.action,
meta.issue.number
);
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
log::info!("Timestamp of this run is {timestamp}");
let token =
std::env::var("GITHUB_TOKEN").context("Failed accessing GITHUB_TOKEN auth token")?;
let server_url = url::Url::parse(
&std::env::var("GITHUB_SERVER_URL")
.context("Failed reading GITHUB_SERVER_URL server url")?,
)
.context("Failed parsing GITHUB_SERVER_URL as a url")?;
let repo_url = server_url
.join(&format!(
"{}/{}",
meta.issue.repository.owner, meta.issue.repository.name
))
.unwrap();
let mut repo_auth_url = repo_url.clone();
repo_auth_url.set_username("forgejo-actions").unwrap();
repo_auth_url.set_password(Some(&token)).unwrap();
let auth = forgejo_api::Auth::Token(&token);
let forgejo =
Forgejo::new(auth, server_url).context("Could not create Forgejo API access object")?;
let ctx = Context {
forgejo,
owner: meta.issue.repository.owner.clone(),
repo: meta.issue.repository.name.clone(),
repo_url,
repo_auth_url,
timestamp,
};
if meta.action == event_meta::IssueAction::Opened {
log::debug!("Running for a newly opened issue. Taking care of the comment first...");
match issue::find_or_make_bot_comment(&ctx, meta.issue.number).await {
Ok((_comment, is_new)) => {
if is_new {
log::info!(
"Waiting for a minute, as the issue is brand new. We will likely catch some more updates this way!"
);
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
}
}
Err(err) => {
log::warn!(
"Error while commenting on new issue #{}, continuing anyway... Error: {err:?}",
meta.issue.number
);
}
}
}
log::info!("Collecting tree from issue metadata...");
let tree = collect::collect_tree(&ctx)
.await
.context("Failed to collect the techtree from issue metadata")?;
log::info!("Rendering and publishing techtree to git repository...");
let rendered_tree = render::render(&ctx, &tree)
.await
.context("Failed to render the techtree")?;
render::publish(&ctx, &rendered_tree)
.await
.context("Failed to publish rendered tree to git")?;
// Wiki is disabled because the tree is too big for mermaid to handle
if false {
log::info!("Updating the wiki overview...");
wiki::update_wiki_from_tree(&ctx, &tree)
.await
.context("Failed to update the techtree wiki page")?;
}
'issues: for issue in tree.iter_issues() {
let subtree = tree.subtree_for_issue(issue).unwrap();
let hash = subtree.stable_hash();
let (bot_comment, _is_new) = issue::find_or_make_bot_comment(&ctx, issue)
.await
.with_context(|| format!("Failed to find or make bot comment in issue #{issue}"))?;
if bot_comment.body.contains(&hash) {
log::info!("Issue #{issue} is up-to-date, not editing comment.");
issue::remove_stale_label(&ctx, issue)
.await
.with_context(|| format!("Failed to remove `Stale` label from issue #{issue}"))?;
continue 'issues;
}
log::info!("Updating bot comment in issue #{issue} ...");
issue::update_bot_comment_from_subtree(&ctx, bot_comment.id, &subtree, &hash)
.await
.with_context(|| format!("Failed to update bot comment in issue #{issue}"))?;
issue::remove_stale_label(&ctx, issue)
.await
.with_context(|| format!("Failed to remove `Stale` label from issue #{issue}"))?;
}
Ok(())
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
run().await
}

View file

@ -0,0 +1,112 @@
use std::process::Command;
use anyhow::Context as _;
trait SuccessExt {
fn success(&mut self) -> anyhow::Result<()>;
}
impl SuccessExt for Command {
fn success(&mut self) -> anyhow::Result<()> {
if !self.status()?.success() {
anyhow::bail!("Command returned unsuccessfully");
}
Ok(())
}
}
pub struct RenderedTree {
repo_dir: std::path::PathBuf,
dot_file: std::path::PathBuf,
svg_file: std::path::PathBuf,
}
pub async fn render(
_ctx: &crate::Context,
tree: &crate::tree::Tree,
) -> anyhow::Result<RenderedTree> {
let repo_dir = std::path::PathBuf::from("render-git");
if repo_dir.is_dir() {
log::info!("Found old {repo_dir:?} repository, removing...");
std::fs::remove_dir_all(&repo_dir).context("Failed to remove stale render repository")?;
}
std::fs::create_dir(&repo_dir).context("Failed creating directory for rendered graph")?;
let dot_file = repo_dir.join("techtree.dot");
let svg_file = repo_dir.join("techtree.svg");
let dot_source = tree.to_dot();
std::fs::write(&dot_file, dot_source.as_bytes())
.context("Failed to write `dot` source file")?;
Command::new("dot")
.args(["-T", "svg"])
.arg("-o")
.arg(&svg_file)
.arg(&dot_file)
.success()
.context("Failed to generate svg graph from dot source")?;
Ok(RenderedTree {
repo_dir,
dot_file,
svg_file,
})
}
pub async fn publish(ctx: &crate::Context, rendered: &RenderedTree) -> anyhow::Result<()> {
Command::new("git")
.arg("-C")
.arg(&rendered.repo_dir)
.arg("init")
.arg("--initial-branch=render")
.arg("--quiet")
.success()
.context("Failed to initialize render repository")?;
Command::new("git")
.arg("-C")
.arg(&rendered.repo_dir)
.args(["config", "user.email", "git@fa-fo.de"])
.success()
.context("Failed to configure identity for render repo")?;
Command::new("git")
.arg("-C")
.arg(&rendered.repo_dir)
.args(["config", "user.name", "Forgejo Actions"])
.success()
.context("Failed to configure identity for render repo")?;
Command::new("git")
.arg("-C")
.arg(&rendered.repo_dir)
.arg("add")
.arg(rendered.dot_file.strip_prefix(&rendered.repo_dir).unwrap())
.arg(rendered.svg_file.strip_prefix(&rendered.repo_dir).unwrap())
.success()
.context("Failed to add generated graph files to git index")?;
Command::new("git")
.arg("-C")
.arg(&rendered.repo_dir)
.arg("commit")
.args(["-m", &format!("Updated techtree at {}", ctx.timestamp)])
.success()
.context("Failed to add generated graph files to git index")?;
Command::new("git")
.arg("-C")
.arg(&rendered.repo_dir)
.arg("push")
.arg("--force")
.arg("--quiet")
.arg(ctx.repo_auth_url.to_string())
.arg("HEAD:refs/heads/render")
.status()
.context("Failed to push rendered graph to forgejo repository")?;
Ok(())
}

View file

@ -0,0 +1,353 @@
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: String,
/// 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}}}|<I>{ty}</I>|<B>{description}</B>}}>"##
));
attributes.push(r#"shape = "record""#.to_owned());
let (color, background) = match (role, status) {
(Some(SubtreeElementRole::ElementOfInterest), _) => ("black", "white"),
(Some(SubtreeElementRole::Dependant), _) => ("gray", "gray"),
(_, ElementStatus::Missing) => ("#800", "#fcc"),
(_, ElementStatus::Assigned) => ("#a50", "#ffa"),
(_, ElementStatus::Completed) => ("#080", "#afa"),
};
attributes.push(format!(r#"color = "{color}""#));
attributes.push(format!(r#"fontcolor = "{color}""#));
attributes.push(format!(r#"fillcolor = "{background}""#));
attributes.push(format!(r#"style = "filled""#));
attributes.join(", ")
}
fn to_mermaid_node(
&self,
index: ElementIndex,
role: Option<SubtreeElementRole>,
repo_url: &str,
simple: bool,
) -> String {
let Element {
issue_number,
description,
ty,
status,
} = self;
let label = if simple {
format!(r##"#{issue_number} | {status}\n<i>{ty}</i>\n<b>{description}</b>"##)
} else {
format!(
r##"<a href='{repo_url}/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, color:#000;
classDef dependant fill:#fff, stroke:#888, color:#888;
classDef dep_missing fill:#fcc, stroke:#800, color:#000;
classDef dep_assigned fill:#ffa, stroke:#a50, color:#000;
classDef dep_completed fill:#afa, stroke:#080, color:#000;
"##
.to_owned()
}
#[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),
petgraph::dot::Config::GraphContentOnly,
],
&|_g, _edge_id| "".to_string(),
&|_g, (_, element)| element.to_dot_node_attributes(None),
);
format!(
r#"digraph {{
ranksep=1.2
{:?}
}}
"#,
dot
)
}
pub fn to_mermaid(&self, repo_url: &str, simple: bool) -> 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, repo_url, simple));
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, repo_url: &str, simple: bool) -> 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),
repo_url,
simple,
));
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)
}
}

View file

@ -0,0 +1,40 @@
use anyhow::Context as _;
use base64::prelude::*;
pub async fn update_wiki_from_tree(
ctx: &crate::Context,
tree: &crate::tree::Tree,
) -> anyhow::Result<()> {
let mermaid = tree.to_mermaid(&ctx.repo_url.to_string(), false);
let wiki_text = format!(
r##"This page is automatically updated to show the latest and greatest FAFO techtree:
```mermaid
{mermaid}
```
"##
);
update_wiki_overview(&ctx, wiki_text)
.await
.context("Failed to update the techtree wiki page")?;
Ok(())
}
pub async fn update_wiki_overview(ctx: &crate::Context, new_body: String) -> anyhow::Result<()> {
// TODO: Figure out why we get a 404 when the edit was successfull...
let _ = ctx
.forgejo
.repo_edit_wiki_page(
&ctx.owner,
&ctx.repo,
"Home",
forgejo_api::structs::CreateWikiPageOptions {
content_base64: Some(BASE64_STANDARD.encode(new_body.as_bytes())),
message: Some(format!("Updated to latest model at {}", ctx.timestamp)),
title: Some("Home".to_owned()),
},
)
.await
.context("Failed editing the wiki page");
Ok(())
}

View file

@ -1,184 +0,0 @@
digraph {
ranksep=1.2
rankdir="BT"
0 [ label = <{{#76 | ASSIGNED}|<I>Process</I>|<B>Video Streaming</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
1 [ label = <{{#75 | ASSIGNED}|<I>Equipment</I>|<B>Microphone(s)</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
2 [ label = <{{#74 | ASSIGNED}|<I>Equipment</I>|<B>Camcorder</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
3 [ label = <{{#73 | ASSIGNED}|<I>Development</I>|<B>Main Camera Rig</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
4 [ label = <{{#72 | MISSING}|<I>Process</I>|<B>STM Non-metallic imaging</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
5 [ label = <{{#71 | MISSING}|<I>Research</I>|<B>STM Metal Pattern Deposition</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
6 [ label = <{{#70 | MISSING}|<I>Research</I>|<B>STM Lithography</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
7 [ label = <{{#69 | MISSING}|<I>Research</I>|<B>STM Surface Topography Manipulation</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
8 [ label = <{{#68 | ASSIGNED}|<I>Equipment</I>|<B>Heating Stirrer</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
9 [ label = <{{#67 | MISSING}|<I>Equipment</I>|<B>Chemical Storage/Containment</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
10 [ label = <{{#66 | ASSIGNED}|<I>Process</I>|<B>Video Recording</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
11 [ label = <{{#65 | MISSING}|<I>Process</I>|<B>Gas chromatography</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
12 [ label = <{{#64 | COMPLETED}|<I>Equipment</I>|<B>FFF 3D-Printer</B>}>, shape = "record", color = "#080", fontcolor = "#080", fillcolor = "#afa", style = "filled"]
13 [ label = <{{#63 | MISSING}|<I>Equipment</I>|<B>OpenFlexure Delta Stage</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
14 [ label = <{{#62 | MISSING}|<I>Process</I>|<B>Lithography</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
15 [ label = <{{#61 | MISSING}|<I>Process</I>|<B>Zeloof Z1 process</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
16 [ label = <{{#60 | MISSING}|<I>Process</I>|<B>Thermal diffusion doping</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
17 [ label = <{{#59 | MISSING}|<I>Process</I>|<B>H₃PO₄/HNO₃/AcOH Etching</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
18 [ label = <{{#58 | MISSING}|<I>Process</I>|<B>Pattern Al</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
19 [ label = <{{#57 | MISSING}|<I>Process</I>|<B>Metal thin film deposition</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
20 [ label = <{{#56 | MISSING}|<I>Process</I>|<B>PVD: Thermal Evaporation</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
21 [ label = <{{#55 | MISSING}|<I>Equipment</I>|<B>Tube Furnace</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
22 [ label = <{{#54 | MISSING}|<I>Process</I>|<B>Si Thermal Oxidation</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
23 [ label = <{{#53 | MISSING}|<I>Process</I>|<B>Pattern SiO₂</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
24 [ label = <{{#52 | MISSING}|<I>Development</I>|<B>Maskless photolithography stepper</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
25 [ label = <{{#51 | MISSING}|<I>Process</I>|<B>Maskless Photolithography</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
26 [ label = <{{#50 | MISSING}|<I>Process</I>|<B>E-beam Lithography</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
27 [ label = <{{#49 | MISSING}|<I>Development</I>|<B>Spin Coater</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
28 [ label = <{{#48 | MISSING}|<I>Process</I>|<B>STM Imaging Individual Atoms</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
29 [ label = <{{#47 | MISSING}|<I>Process</I>|<B>STM Imaging@1nm</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
30 [ label = <{{#46 | MISSING}|<I>Process</I>|<B>STM Imaging@10nm</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
31 [ label = <{{#45 | MISSING}|<I>Process</I>|<B>STM Imaging@100nm</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
32 [ label = <{{#44 | MISSING}|<I>Process</I>|<B>STM Imaging@1µm</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
33 [ label = <{{#43 | ASSIGNED}|<I>Development</I>|<B>Scanning Tunneling Microscope (STM)</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
34 [ label = <{{#42 | ASSIGNED}|<I>Development</I>|<B>STM Vibration Isolation</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
35 [ label = <{{#41 | COMPLETED}|<I>Process</I>|<B>Primitive Die Imaging</B>}>, shape = "record", color = "#080", fontcolor = "#080", fillcolor = "#afa", style = "filled"]
36 [ label = <{{#40 | ASSIGNED}|<I>Equipment</I>|<B>Air Compressor</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
37 [ label = <{{#39 | MISSING}|<I>Development</I>|<B>OBI Lite</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
38 [ label = <{{#38 | MISSING}|<I>Process</I>|<B>SEM Non-Metallic Imaging</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
39 [ label = <{{#37 | MISSING}|<I>Process</I>|<B>PVD: Sputtering</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
40 [ label = <{{#36 | MISSING}|<I>Process</I>|<B>Primitive Chip Imaging</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
41 [ label = <{{#35 | MISSING}|<I>Equipment</I>|<B>SEM Beam Blanker</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
42 [ label = <{{#34 | COMPLETED}|<I>Equipment</I>|<B>Open Beam Interface</B>}>, shape = "record", color = "#080", fontcolor = "#080", fillcolor = "#afa", style = "filled"]
43 [ label = <{{#33 | ASSIGNED}|<I>Equipment</I>|<B>SEM Vibration Isolation</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
44 [ label = <{{#32 | MISSING}|<I>Process</I>|<B>SEM Imaging@10nm</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
45 [ label = <{{#31 | MISSING}|<I>Process</I>|<B>SEM Imaging@100nm</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
46 [ label = <{{#30 | MISSING}|<I>Process</I>|<B>SEM Imaging@1µm</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
47 [ label = <{{#29 | COMPLETED}|<I>Process</I>|<B>SEM Imaging@10µm</B>}>, shape = "record", color = "#080", fontcolor = "#080", fillcolor = "#afa", style = "filled"]
48 [ label = <{{#28 | COMPLETED}|<I>Equipment</I>|<B>Scanning Electron Microscope (SEM)</B>}>, shape = "record", color = "#080", fontcolor = "#080", fillcolor = "#afa", style = "filled"]
49 [ label = <{{#27 | ASSIGNED}|<I>Equipment</I>|<B>Technical Ventilation</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
50 [ label = <{{#26 | COMPLETED}|<I>Equipment</I>|<B>Water Cooling</B>}>, shape = "record", color = "#080", fontcolor = "#080", fillcolor = "#afa", style = "filled"]
51 [ label = <{{#25 | MISSING}|<I>Equipment</I>|<B>Plasma Etcher</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
52 [ label = <{{#24 | ASSIGNED}|<I>Equipment</I>|<B>~3ph Power (below 10kW)</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
53 [ label = <{{#23 | MISSING}|<I>Equipment</I>|<B>Fume Hood</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
54 [ label = <{{#22 | MISSING}|<I>Equipment</I>|<B>Running Water and Sink</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
55 [ label = <{{#21 | MISSING}|<I>Process</I>|<B>Reactive Ion Etching</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
56 [ label = <{{#20 | MISSING}|<I>Process</I>|<B>Body Bias Injection</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
57 [ label = <{{#19 | MISSING}|<I>Process</I>|<B>IC Fault Injection</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
58 [ label = <{{#18 | MISSING}|<I>Research</I>|<B>SEM Fault Injection</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
59 [ label = <{{#17 | MISSING}|<I>Process</I>|<B>SEM Nanoprobing/Live Analysis</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
60 [ label = <{{#16 | MISSING}|<I>Process</I>|<B>Laser Fault Injection</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
61 [ label = <{{#15 | MISSING}|<I>Process</I>|<B>Microprobing</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
62 [ label = <{{#14 | MISSING}|<I>Equipment</I>|<B>Optical Microscope</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
63 [ label = <{{#13 | MISSING}|<I>Process</I>|<B>Full Chip Reverse Engineering</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
64 [ label = <{{#12 | MISSING}|<I>Process</I>|<B>DASH Stain</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
65 [ label = <{{#11 | MISSING}|<I>Process</I>|<B>Simple Chip Imaging</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
66 [ label = <{{#10 | ASSIGNED}|<I>Equipment</I>|<B>SEM: Motorized Stage</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
67 [ label = <{{#9 | MISSING}|<I>Equipment</I>|<B>Optical Microscope: Motorized Stage</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
68 [ label = <{{#8 | MISSING}|<I>Process</I>|<B>Plasma Etching</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
69 [ label = <{{#7 | MISSING}|<I>Equipment</I>|<B>Fiber Laser</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
70 [ label = <{{#6 | MISSING}|<I>Process</I>|<B>HNO₃/H₂SO₄ Etching</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
71 [ label = <{{#5 | MISSING}|<I>Process</I>|<B>HNO₃/HF Etching</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
72 [ label = <{{#4 | ASSIGNED}|<I>Equipment</I>|<B>Wet Lab (Chemistry)</B>}>, shape = "record", color = "#a50", fontcolor = "#a50", fillcolor = "#ffa", style = "filled"]
73 [ label = <{{#3 | MISSING}|<I>Equipment</I>|<B>Lapping Machine</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
74 [ label = <{{#2 | MISSING}|<I>Process</I>|<B>Chip Delayering</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
75 [ label = <{{#1 | MISSING}|<I>Process</I>|<B>Chip Decapping</B>}>, shape = "record", color = "#800", fontcolor = "#800", fillcolor = "#fcc", style = "filled"]
0 -> 3 [ ]
3 -> 1 [ ]
3 -> 2 [ ]
4 -> 19 [ ]
4 -> 33 [ ]
5 -> 33 [ ]
6 -> 27 [ ]
6 -> 33 [ ]
7 -> 29 [ ]
9 -> 49 [ ]
10 -> 3 [ ]
11 -> 72 [ ]
13 -> 12 [ ]
14 -> 6 [ ]
14 -> 25 [ ]
14 -> 26 [ ]
15 -> 16 [ ]
15 -> 18 [ ]
15 -> 23 [ ]
15 -> 61 [ ]
16 -> 21 [ ]
16 -> 72 [ ]
17 -> 72 [ ]
18 -> 5 [ ]
18 -> 14 [ ]
18 -> 17 [ ]
18 -> 19 [ ]
19 -> 20 [ ]
19 -> 39 [ ]
22 -> 21 [ ]
23 -> 14 [ ]
23 -> 22 [ ]
23 -> 55 [ ]
23 -> 71 [ ]
25 -> 24 [ ]
25 -> 27 [ ]
26 -> 27 [ ]
26 -> 41 [ ]
28 -> 29 [ ]
29 -> 30 [ ]
30 -> 31 [ ]
31 -> 32 [ ]
32 -> 33 [ ]
33 -> 34 [ ]
35 -> 47 [ ]
37 -> 42 [ ]
38 -> 19 [ ]
38 -> 48 [ ]
40 -> 35 [ ]
40 -> 75 [ ]
41 -> 48 [ ]
42 -> 48 [ ]
43 -> 36 [ ]
43 -> 48 [ ]
44 -> 45 [ ]
45 -> 43 [ ]
45 -> 46 [ ]
46 -> 47 [ ]
47 -> 48 [ ]
48 -> 50 [ ]
51 -> 49 [ ]
51 -> 52 [ ]
53 -> 49 [ ]
55 -> 51 [ ]
56 -> 75 [ ]
57 -> 56 [ ]
57 -> 58 [ ]
57 -> 60 [ ]
57 -> 61 [ ]
58 -> 41 [ ]
58 -> 59 [ ]
59 -> 66 [ ]
59 -> 74 [ ]
60 -> 67 [ ]
60 -> 75 [ ]
61 -> 67 [ ]
63 -> 64 [ ]
63 -> 65 [ ]
64 -> 72 [ ]
65 -> 40 [ ]
65 -> 45 [ ]
65 -> 66 [ ]
65 -> 74 [ ]
66 -> 48 [ ]
67 -> 13 [ ]
67 -> 62 [ ]
68 -> 51 [ ]
70 -> 72 [ ]
71 -> 72 [ ]
72 -> 8 [ ]
72 -> 9 [ ]
72 -> 49 [ ]
72 -> 53 [ ]
72 -> 54 [ ]
74 -> 55 [ ]
74 -> 69 [ ]
74 -> 71 [ ]
74 -> 73 [ ]
74 -> 75 [ ]
75 -> 68 [ ]
75 -> 69 [ ]
75 -> 70 [ ]
75 -> 73 [ ]
}

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 103 KiB