summary refs log tree commit diff stats
path: root/pkgs/by-name
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/by-name')
-rw-r--r--pkgs/by-name/ba/back/Cargo.lock66
-rw-r--r--pkgs/by-name/ba/back/Cargo.toml1
-rw-r--r--pkgs/by-name/ba/back/flake.nix2
-rw-r--r--pkgs/by-name/ba/back/src/error/mod.rs44
-rw-r--r--pkgs/by-name/ba/back/src/error/responder.rs23
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/dag/mod.rs143
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/format/mod.rs (renamed from pkgs/by-name/ba/back/src/web/format/mod.rs)63
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/issue/entity/mod.rs78
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/issue/identity/mod.rs71
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/issue/label/mod.rs85
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/issue/mod.rs185
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/issue/operation/mod.rs124
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/issue/operation/operation_type.rs51
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/mod.rs28
-rw-r--r--pkgs/by-name/ba/back/src/main.rs1
-rw-r--r--pkgs/by-name/ba/back/src/web/issue/mod.rs337
-rw-r--r--pkgs/by-name/ba/back/src/web/issue/raw.rs145
-rw-r--r--pkgs/by-name/ba/back/src/web/issue_html.rs166
-rw-r--r--pkgs/by-name/ba/back/src/web/mod.rs148
-rw-r--r--pkgs/by-name/ba/back/src/web/prefix.rs (renamed from pkgs/by-name/ba/back/src/web/issue_show.rs)1
20 files changed, 1187 insertions, 575 deletions
diff --git a/pkgs/by-name/ba/back/Cargo.lock b/pkgs/by-name/ba/back/Cargo.lock
index b72f23c..3965cfc 100644
--- a/pkgs/by-name/ba/back/Cargo.lock
+++ b/pkgs/by-name/ba/back/Cargo.lock
@@ -196,6 +196,7 @@ dependencies = [
  "rocket",
  "serde",
  "serde_json",
+ "sha2",
  "thiserror",
  "url",
 ]
@@ -228,6 +229,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
 
 [[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
 name = "bstr"
 version = "1.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -367,6 +377,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
 
 [[package]]
+name = "cpufeatures"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
 name = "crc32fast"
 version = "1.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -391,6 +410,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
 
 [[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
 name = "dashmap"
 version = "6.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -447,6 +476,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
 name = "displaydoc"
 version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -639,6 +678,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
 name = "getrandom"
 version = "0.2.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2557,6 +2606,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
 
 [[package]]
+name = "sha2"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
 name = "sharded-slab"
 version = "0.1.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2944,6 +3004,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
 
 [[package]]
+name = "typenum"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+
+[[package]]
 name = "ubyte"
 version = "0.10.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/pkgs/by-name/ba/back/Cargo.toml b/pkgs/by-name/ba/back/Cargo.toml
index 2fc0600..8a472e6 100644
--- a/pkgs/by-name/ba/back/Cargo.toml
+++ b/pkgs/by-name/ba/back/Cargo.toml
@@ -29,6 +29,7 @@ markdown = "1.0.0-alpha.21"
 rocket = "0.5.1"
 serde = "1.0.216"
 serde_json = "1.0.134"
+sha2 = "0.10.8"
 thiserror = "2.0.9"
 url = { version = "2.5.4", features = ["serde"] }
 
diff --git a/pkgs/by-name/ba/back/flake.nix b/pkgs/by-name/ba/back/flake.nix
index b7e158e..2553cdf 100644
--- a/pkgs/by-name/ba/back/flake.nix
+++ b/pkgs/by-name/ba/back/flake.nix
@@ -31,6 +31,8 @@
         rust-analyzer
         cargo-edit
 
+        git-bug
+
         reuse
       ];
     };
diff --git a/pkgs/by-name/ba/back/src/error/mod.rs b/pkgs/by-name/ba/back/src/error/mod.rs
index 7e1c9cf..8b71700 100644
--- a/pkgs/by-name/ba/back/src/error/mod.rs
+++ b/pkgs/by-name/ba/back/src/error/mod.rs
@@ -1,9 +1,24 @@
+// Back - An extremely simple git issue tracking system. Inspired by tvix's
+// panettone
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of Back.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/agpl.txt>.
+
 use std::{fmt::Display, io, path::PathBuf};
 
 use thiserror::Error;
 
+use crate::web::prefix::BackPrefix;
+
 pub type Result<T> = std::result::Result<T, Error>;
 
+pub mod responder;
+
 #[derive(Error, Debug)]
 pub enum Error {
     ConfigParse {
@@ -14,11 +29,19 @@ pub enum Error {
         file: PathBuf,
         error: io::Error,
     },
+    RocketLaunch(#[from] rocket::Error),
+
     RepoOpen {
         repository_path: PathBuf,
         error: Box<gix::open::Error>,
     },
-    RocketLaunch(#[from] rocket::Error),
+    RepoRefsIter(#[from] gix::refs::packed::buffer::open::Error),
+    RepoRefsPrefixed(#[from] std::io::Error),
+
+    IssuesPrefixMissing {
+        prefix: BackPrefix,
+    },
+    IssuesPrefixParse(#[from] gix::hash::prefix::from_hex::Error),
 }
 
 impl Display for Error {
@@ -38,6 +61,9 @@ impl Display for Error {
                     file.display()
                 )
             }
+            Error::RocketLaunch(error) => {
+                write!(f, "while trying to start back: {error}")
+            }
             Error::RepoOpen {
                 repository_path,
                 error,
@@ -48,8 +74,20 @@ impl Display for Error {
                     repository_path.display()
                 )
             }
-            Error::RocketLaunch(error) => {
-                write!(f, "while trying to start back: {error}")
+            Error::RepoRefsIter(error) => {
+                write!(f, "while iteration over the refs in a repository: {error}",)
+            }
+            Error::RepoRefsPrefixed(error) => {
+                write!(f, "while prefixing the refs with a path: {error}")
+            }
+            Error::IssuesPrefixMissing { prefix } => {
+                write!(
+                    f,
+                    "There is no 'issue' associated with the prefix: {prefix}"
+                )
+            }
+            Error::IssuesPrefixParse(error) => {
+                write!(f, "The given prefix can not be parsed as prefix: {error}")
             }
         }
     }
diff --git a/pkgs/by-name/ba/back/src/error/responder.rs b/pkgs/by-name/ba/back/src/error/responder.rs
new file mode 100644
index 0000000..7bea961
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/error/responder.rs
@@ -0,0 +1,23 @@
+// Back - An extremely simple git issue tracking system. Inspired by tvix's
+// panettone
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of Back.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/agpl.txt>.
+
+use rocket::{
+    response::{self, Responder, Response},
+    Request,
+};
+
+use super::Error;
+
+impl<'r> Responder<'r, 'static> for Error {
+    fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> {
+        Response::build_from(self.to_string().respond_to(req)?).ok()
+    }
+}
diff --git a/pkgs/by-name/ba/back/src/git_bug/dag/mod.rs b/pkgs/by-name/ba/back/src/git_bug/dag/mod.rs
new file mode 100644
index 0000000..9c158a7
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/git_bug/dag/mod.rs
@@ -0,0 +1,143 @@
+// Back - An extremely simple git issue tracking system. Inspired by tvix's
+// panettone
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of Back.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/agpl.txt>.
+
+use std::path::Path;
+
+use gix::{bstr::ByteSlice, refs::Target, Commit, Id, ObjectId, Repository};
+
+use crate::error;
+
+use super::issue::{
+    entity::{Entity, RawEntity},
+    CollapsedIssue, RawCollapsedIssue,
+};
+
+#[derive(Debug)]
+pub struct Dag {
+    entities: Vec<Entity>,
+}
+
+impl Dag {
+    pub fn collapse(self) -> CollapsedIssue {
+        let raw_collapsed_issue = self.entities.into_iter().rev().fold(
+            RawCollapsedIssue::default(),
+            |mut collapsed_issue, entity| {
+                collapsed_issue.append_entity(entity);
+                collapsed_issue
+            },
+        );
+
+        CollapsedIssue::from(raw_collapsed_issue)
+    }
+
+    /// Construct a DAG from the root child upwards.
+    pub fn construct(repo: &Repository, id: ObjectId) -> Self {
+        let mut entities = vec![];
+
+        let base_commit = repo
+            .find_object(id)
+            .expect("The object with this id should exist.")
+            .try_into_commit()
+            .expect("The git-bug's data model enforces this.");
+
+        entities.push(Self::commit_to_operations(repo, &base_commit));
+
+        let mut current_commit = base_commit;
+        while let Some(parent_id) = Self::try_get_parent(repo, &current_commit) {
+            entities.push(Self::commit_to_operations(repo, &parent_id));
+            current_commit = parent_id;
+        }
+
+        Self {
+            entities: {
+                entities
+                    .into_iter()
+                    .map(|(raw_entity, id)| Entity::from_raw(repo, raw_entity, id))
+                    .collect()
+            },
+        }
+    }
+
+    fn commit_to_operations<'b>(repo: &Repository, id: &Commit<'b>) -> (RawEntity, Id<'b>) {
+        let tree_obj = repo
+            .find_object(
+                id.tree_id()
+                    .expect("All of git-bug's commits should have trees attached to them'"),
+            )
+            .expect("The object with this id should exist.")
+            .try_into_tree()
+            .expect("git-bug's data model enforces this.");
+
+        let ops_ref = tree_obj
+            .find_entry("ops")
+            .expect("All of git-bug's trees should contain a 'ops' json file");
+
+        let issue_data = repo
+            .find_object(ops_ref.object_id())
+            .expect("The object with this id should exist.")
+            .try_into_blob()
+            .expect("The git-bug's data model enforces this.")
+            .data
+            .clone();
+
+        let operations = serde_json::from_str(
+            issue_data
+                .to_str()
+                .expect("git-bug's ensures, that this is valid json."),
+        )
+        .expect("The returned json should be valid");
+
+        (operations, id.id())
+    }
+
+    fn try_get_parent<'a>(repo: &'a Repository, base_commit: &Commit<'a>) -> Option<Commit<'a>> {
+        let count = base_commit.parent_ids().count();
+
+        match count {
+            0 => None,
+            1 => {
+                let parent = base_commit.parent_ids().last().expect("One does exist");
+
+                let parent_id = parent.object().expect("The object exists").id;
+                Some(
+                    repo.find_object(parent_id)
+                        .expect("This is a valid id")
+                        .try_into_commit()
+                        .expect("This should be a commit"),
+                )
+            }
+            other => {
+                unreachable!(
+                    "Each commit, used by git-bug should only have one parent, but found: {other}"
+                );
+            }
+        }
+    }
+}
+
+pub fn issues_from_repository(repo: &Repository) -> error::Result<Vec<Dag>> {
+    let dags = repo
+        .refs
+        .iter()?
+        .prefixed(Path::new("refs/bugs/"))?
+        .map(|val| {
+            let reference = val.expect("All `git-bug` references in 'refs/bugs' should be objects");
+
+            if let Target::Object(id) = reference.target {
+                Dag::construct(repo, id)
+            } else {
+                unreachable!("All 'refs/bugs/' should contain a clear target.");
+            }
+        })
+        .collect::<Vec<Dag>>();
+
+    Ok(dags)
+}
diff --git a/pkgs/by-name/ba/back/src/web/format/mod.rs b/pkgs/by-name/ba/back/src/git_bug/format/mod.rs
index f78d3b3..4ebf6d4 100644
--- a/pkgs/by-name/ba/back/src/web/format/mod.rs
+++ b/pkgs/by-name/ba/back/src/git_bug/format/mod.rs
@@ -11,41 +11,76 @@
 
 use std::fmt::Display;
 
+use chrono::DateTime;
 use markdown::to_html;
+use serde::Deserialize;
+use serde_json::Value;
 
 #[derive(Debug, Default, Clone)]
-pub struct Markdown {
+/// Markdown content.
+pub struct MarkDown {
     value: String,
 }
 
-impl From<String> for Markdown {
-    fn from(value: String) -> Self {
-        Self { value }
+impl Display for MarkDown {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(to_html(&self.value).as_str())
+    }
+}
+impl From<&Value> for MarkDown {
+    fn from(value: &Value) -> Self {
+        Self {
+            value: value.as_str().expect("This will exist").to_owned(),
+        }
+    }
+}
+
+/// An UNIX time stamp.
+///
+/// These should only ever be used for human-display, because timestamps are unreliably in a
+/// distributed system.
+/// Because of this reason, there is no `value()` function.
+#[derive(Debug, Default, Clone, Copy)]
+pub struct TimeStamp {
+    value: u64,
+}
+impl From<&Value> for TimeStamp {
+    fn from(value: &Value) -> Self {
+        Self {
+            value: value.as_u64().expect("This must exist"),
+        }
     }
 }
-impl Display for Markdown {
+impl Display for TimeStamp {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.write_str(to_html(&self.value).as_str())
+        let date =
+            DateTime::from_timestamp(self.value as i64, 0).expect("This timestamp should be vaild");
+
+        let newdate = date.format("%Y-%m-%d %H:%M:%S");
+        f.write_str(newdate.to_string().as_str())
     }
 }
 
-#[derive(Debug, Default)]
-pub struct BackString {
+#[derive(Debug, Default, Deserialize, Clone, PartialEq, Eq)]
+/// A string that should be escaped when injected into html content.
+pub struct HtmlString {
     value: String,
 }
 
-impl From<Markdown> for BackString {
-    fn from(value: Markdown) -> Self {
+impl From<MarkDown> for HtmlString {
+    fn from(value: MarkDown) -> Self {
         Self { value: value.value }
     }
 }
 
-impl From<String> for BackString {
-    fn from(value: String) -> Self {
-        Self { value }
+impl From<&Value> for HtmlString {
+    fn from(value: &Value) -> Self {
+        Self {
+            value: value.as_str().expect("This will exist").to_owned(),
+        }
     }
 }
-impl Display for BackString {
+impl Display for HtmlString {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.write_str(escape_html(&self.value).as_str())
     }
diff --git a/pkgs/by-name/ba/back/src/git_bug/issue/entity/mod.rs b/pkgs/by-name/ba/back/src/git_bug/issue/entity/mod.rs
new file mode 100644
index 0000000..f2e9af0
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/git_bug/issue/entity/mod.rs
@@ -0,0 +1,78 @@
+// Back - An extremely simple git issue tracking system. Inspired by tvix's
+// panettone
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of Back.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/agpl.txt>.
+
+use std::fmt::Display;
+
+use gix::Repository;
+use serde::Deserialize;
+use serde_json::Value;
+
+use super::{
+    identity::{Author, RawAuthor},
+    operation::Operation,
+};
+
+#[derive(Deserialize, Debug, PartialEq, Eq, Clone)]
+#[serde(from = "Value")]
+pub struct Id {
+    value: String,
+}
+impl From<Value> for Id {
+    fn from(value: Value) -> Self {
+        Self::from(&value)
+    }
+}
+impl From<&Value> for Id {
+    fn from(value: &Value) -> Self {
+        Self {
+            value: value.as_str().expect("This should  be a string").to_owned(),
+        }
+    }
+}
+impl From<gix::Id<'_>> for Id {
+    fn from(value: gix::Id<'_>) -> Self {
+        Self {
+            value: value.shorten().expect("This should work?").to_string(),
+        }
+    }
+}
+impl Display for Id {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.value.fmt(f)
+        // let shortend = self.value.shorten().expect("This should work.");
+        // f.write_str(shortend.to_string().as_str())
+    }
+}
+
+#[derive(Debug)]
+pub struct Entity {
+    pub id: Id,
+    pub author: Author,
+    pub operations: Vec<Operation>,
+}
+
+impl Entity {
+    pub fn from_raw<'a>(repo: &'a Repository, raw: RawEntity, id: gix::Id<'a>) -> Self {
+        Self {
+            id: Id::from(id),
+            author: Author::construct(repo, raw.author),
+            operations: raw.operations,
+        }
+    }
+}
+
+#[derive(Deserialize)]
+pub struct RawEntity {
+    pub author: RawAuthor,
+
+    #[serde(alias = "ops")]
+    pub operations: Vec<Operation>,
+}
diff --git a/pkgs/by-name/ba/back/src/git_bug/issue/identity/mod.rs b/pkgs/by-name/ba/back/src/git_bug/issue/identity/mod.rs
new file mode 100644
index 0000000..0c2f426
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/git_bug/issue/identity/mod.rs
@@ -0,0 +1,71 @@
+// Back - An extremely simple git issue tracking system. Inspired by tvix's
+// panettone
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of Back.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/agpl.txt>.
+
+use gix::{bstr::ByteSlice, Repository};
+use serde::Deserialize;
+use serde_json::Value;
+
+use crate::{get, git_bug::format::HtmlString};
+
+use super::entity::Id;
+
+#[derive(Debug, Clone)]
+pub struct Author {
+    pub name: HtmlString,
+    pub email: HtmlString,
+    pub id: Id,
+}
+
+impl Author {
+    pub fn construct(repo: &Repository, raw: RawAuthor) -> Self {
+        let commit_obj = repo
+            .find_reference(&format!("refs/identities/{}", raw.id))
+            .expect("All authors should also have identities")
+            .peel_to_commit()
+            .expect("All identities should be commits");
+
+        let tree_obj = repo
+            .find_tree(
+                commit_obj
+                    .tree()
+                    .expect("The commit should have an tree associated with it")
+                    .id,
+            )
+            .expect("This should be a tree");
+
+        let data = repo
+            .find_blob(
+                tree_obj
+                    .find_entry("version")
+                    .expect("This entry should exist")
+                    .object()
+                    .expect("This should point to a blob entry")
+                    .id,
+            )
+            .expect("This blob should exist")
+            .data
+            .clone();
+
+        let json: Value = serde_json::from_str(data.to_str().expect("This is encoded json"))
+            .expect("This is valid json");
+
+        Author {
+            name: get! {json, "name"},
+            email: get! {json, "email"},
+            id: raw.id,
+        }
+    }
+}
+
+#[derive(Deserialize)]
+pub struct RawAuthor {
+    id: Id,
+}
diff --git a/pkgs/by-name/ba/back/src/git_bug/issue/label/mod.rs b/pkgs/by-name/ba/back/src/git_bug/issue/label/mod.rs
new file mode 100644
index 0000000..a971234
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/git_bug/issue/label/mod.rs
@@ -0,0 +1,85 @@
+// Back - An extremely simple git issue tracking system. Inspired by tvix's
+// panettone
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of Back.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/agpl.txt>.
+
+use std::fmt::Display;
+
+use serde::Deserialize;
+use sha2::{Digest, Sha256};
+
+use crate::git_bug::format::HtmlString;
+
+#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
+pub struct Label {
+    value: HtmlString,
+}
+
+impl Display for Label {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.value.fmt(f)
+    }
+}
+
+impl Label {
+    /// RGBA from a Label computed in a deterministic way
+    /// This is taken completely from `git_bug`
+    pub fn associate_color(&self) -> Color {
+        // colors from: https://material-ui.com/style/color/
+        let colors = vec![
+            Color::from_rgba(244, 67, 54, 255),   // red
+            Color::from_rgba(233, 30, 99, 255),   // pink
+            Color::from_rgba(156, 39, 176, 255),  // purple
+            Color::from_rgba(103, 58, 183, 255),  // deepPurple
+            Color::from_rgba(63, 81, 181, 255),   // indigo
+            Color::from_rgba(33, 150, 243, 255),  // blue
+            Color::from_rgba(3, 169, 244, 255),   // lightBlue
+            Color::from_rgba(0, 188, 212, 255),   // cyan
+            Color::from_rgba(0, 150, 136, 255),   // teal
+            Color::from_rgba(76, 175, 80, 255),   // green
+            Color::from_rgba(139, 195, 74, 255),  // lightGreen
+            Color::from_rgba(205, 220, 57, 255),  // lime
+            Color::from_rgba(255, 235, 59, 255),  // yellow
+            Color::from_rgba(255, 193, 7, 255),   // amber
+            Color::from_rgba(255, 152, 0, 255),   // orange
+            Color::from_rgba(255, 87, 34, 255),   // deepOrange
+            Color::from_rgba(121, 85, 72, 255),   // brown
+            Color::from_rgba(158, 158, 158, 255), // grey
+            Color::from_rgba(96, 125, 139, 255),  // blueGrey
+        ];
+
+        let hash = Sha256::digest(self.to_string().as_bytes());
+
+        let id: usize = hash
+            .into_iter()
+            .map(|val| val as usize)
+            .fold(0, |acc, val| (acc + val) % colors.len());
+
+        colors[id]
+    }
+}
+
+#[derive(Default, Clone, Copy, Debug)]
+pub struct Color {
+    pub red: u32,
+    pub green: u32,
+    pub blue: u32,
+    pub alpha: u32,
+}
+
+impl Color {
+    pub fn from_rgba(red: u32, green: u32, blue: u32, alpha: u32) -> Self {
+        Self {
+            red,
+            green,
+            blue,
+            alpha,
+        }
+    }
+}
diff --git a/pkgs/by-name/ba/back/src/git_bug/issue/mod.rs b/pkgs/by-name/ba/back/src/git_bug/issue/mod.rs
new file mode 100644
index 0000000..f27bfec
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/git_bug/issue/mod.rs
@@ -0,0 +1,185 @@
+// Back - An extremely simple git issue tracking system. Inspired by tvix's
+// panettone
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of Back.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/agpl.txt>.
+
+use std::fmt::Display;
+
+use entity::{Entity, Id};
+use identity::Author;
+use label::Label;
+use operation::Operation;
+use serde_json::Value;
+
+use super::format::{MarkDown, TimeStamp};
+
+pub mod entity;
+pub mod identity;
+pub mod label;
+pub mod operation;
+
+#[derive(Debug, Eq, PartialEq, Copy, Clone)]
+pub enum Status {
+    Open,
+    Closed,
+}
+impl From<&Value> for Status {
+    fn from(value: &Value) -> Self {
+        match value.as_u64().expect("This should be a integer") {
+            1 => Self::Open,
+            2 => Self::Closed,
+            other => unimplemented!("Invalid status string: '{other}'"),
+        }
+    }
+}
+impl Display for Status {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Status::Open => f.write_str("Open"),
+            Status::Closed => f.write_str("Closed"),
+        }
+    }
+}
+
+#[derive(Debug)]
+pub struct CollapsedIssue {
+    pub id: Id,
+    pub author: Author,
+    pub timestamp: TimeStamp,
+    pub title: MarkDown,
+    pub message: MarkDown,
+    pub comments: Vec<Comment>,
+    pub status: Status,
+    pub last_status_change: TimeStamp,
+    pub labels: Vec<Label>,
+}
+impl From<RawCollapsedIssue> for CollapsedIssue {
+    fn from(r: RawCollapsedIssue) -> Self {
+        macro_rules! get {
+            ($name:ident) => {
+                r.$name.expect(concat!(
+                    "'",
+                    stringify!($name),
+                    "' is unset, when trying to collapes an issue! (This is likely a bug)"
+                ))
+            };
+        }
+
+        Self {
+            id: get! {id},
+            author: get! {author},
+            timestamp: get! {timestamp},
+            title: get! {title},
+            message: get! {message},
+            comments: r.comments,
+            status: get! {status},
+            last_status_change: get! {last_status_change},
+            labels: r.labels,
+        }
+    }
+}
+
+#[derive(Debug)]
+pub struct Comment {
+    pub id: Id,
+    pub author: Author,
+    pub timestamp: TimeStamp,
+    pub message: MarkDown,
+}
+
+#[derive(Debug, Default)]
+pub struct RawCollapsedIssue {
+    pub id: Option<Id>,
+    pub author: Option<Author>,
+    pub timestamp: Option<TimeStamp>,
+    pub title: Option<MarkDown>,
+    pub message: Option<MarkDown>,
+    pub status: Option<Status>,
+    pub last_status_change: Option<TimeStamp>,
+
+    // NOTE(@bpeetz): These values set here already, because an issue without these
+    // would be perfectly valid. <2024-12-26>
+    pub labels: Vec<Label>,
+    pub comments: Vec<Comment>,
+}
+
+impl RawCollapsedIssue {
+    pub fn append_entity(&mut self, entity: Entity) {
+        for op in entity.operations {
+            match op {
+                Operation::AddComment { timestamp, message } => {
+                    self.comments.push(Comment {
+                        id: entity.id.clone(),
+                        author: entity.author.clone(),
+                        timestamp,
+                        message,
+                    });
+                }
+                Operation::Create {
+                    timestamp,
+                    title,
+                    message,
+                } => {
+                    self.id = Some(entity.id.clone());
+                    self.author = Some(entity.author.clone());
+                    self.timestamp = Some(timestamp.clone());
+                    self.title = Some(title);
+                    self.message = Some(message);
+                    self.status = Some(Status::Open); // This is the default in git_bug
+                    self.last_status_change = Some(timestamp);
+                }
+                Operation::EditComment {
+                    timestamp,
+                    target,
+                    message,
+                } => {
+                    let comments = &mut self.comments;
+
+                    let target_comment = comments
+                        .iter_mut()
+                        .find(|comment| comment.id == target)
+                        .expect("The target must be a valid comment");
+
+                    // TODO(@bpeetz): We should probably set a `edited = true` flag here. <2024-12-26>
+                    // TODO(@bpeetz): Should we also change the author? <2024-12-26>
+
+                    target_comment.timestamp = timestamp;
+                    target_comment.message = message;
+                }
+                Operation::LabelChange {
+                    timestamp: _,
+                    added,
+                    removed,
+                } => {
+                    let labels = self.labels.clone();
+
+                    self.labels = labels
+                        .into_iter()
+                        .filter(|val| !removed.contains(val))
+                        .chain(added.into_iter())
+                        .collect();
+                }
+                Operation::SetStatus { timestamp, status } => {
+                    self.status = Some(status);
+                    self.last_status_change = Some(timestamp);
+                }
+                Operation::SetTitle {
+                    timestamp: _,
+                    title,
+                    was: _,
+                } => {
+                    self.title = Some(title);
+                }
+
+                Operation::NoOp {} => unimplemented!(),
+                Operation::SetMetadata {} => unimplemented!(),
+            }
+        }
+    }
+}
diff --git a/pkgs/by-name/ba/back/src/git_bug/issue/operation/mod.rs b/pkgs/by-name/ba/back/src/git_bug/issue/operation/mod.rs
new file mode 100644
index 0000000..7f861a7
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/git_bug/issue/operation/mod.rs
@@ -0,0 +1,124 @@
+// Back - An extremely simple git issue tracking system. Inspired by tvix's
+// panettone
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of Back.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/agpl.txt>.
+
+use std::convert::Infallible;
+
+use operation_type::OperationType;
+use serde::Deserialize;
+use serde_json::Value;
+
+use crate::{
+    get,
+    git_bug::format::{MarkDown, TimeStamp},
+};
+
+use super::{entity, label::Label, Status};
+
+pub mod operation_type;
+
+#[derive(Deserialize, Debug)]
+#[serde(try_from = "Value")]
+pub enum Operation {
+    AddComment {
+        timestamp: TimeStamp,
+        message: MarkDown,
+    },
+    Create {
+        timestamp: TimeStamp,
+        title: MarkDown,
+        message: MarkDown,
+    },
+    EditComment {
+        timestamp: TimeStamp,
+        target: entity::Id,
+        message: MarkDown,
+    },
+    LabelChange {
+        timestamp: TimeStamp,
+        added: Vec<Label>,
+        removed: Vec<Label>,
+    },
+    SetStatus {
+        timestamp: TimeStamp,
+        status: Status,
+    },
+    SetTitle {
+        timestamp: TimeStamp,
+        title: MarkDown,
+        was: MarkDown,
+    },
+
+    // These seem to be just weird non-operation, operations.
+    // defined in: git-bug/entities/bug/operation.go
+    NoOp {},
+    SetMetadata {},
+}
+
+impl TryFrom<Value> for Operation {
+    type Error = Infallible;
+
+    fn try_from(value: Value) -> Result<Self, Self::Error> {
+        let operation_type = OperationType::from_json_int(
+            value
+                .get("type")
+                .expect("Should exist")
+                .as_u64()
+                .expect("This should work"),
+        );
+
+        let op = match operation_type {
+            OperationType::AddComment => Self::AddComment {
+                timestamp: get! {value, "timestamp" },
+                message: get! {value, "message"},
+            },
+            OperationType::Create => Self::Create {
+                timestamp: get! {value, "timestamp"},
+                title: get! {value, "title"},
+                message: get! {value, "message"},
+            },
+            OperationType::EditComment => Self::EditComment {
+                timestamp: get! {value, "timestamp"},
+                target: get! {value, "target"},
+                message: get! {value, "message"},
+            },
+            OperationType::LabelChange => Self::LabelChange {
+                timestamp: get! {value, "timestamp"},
+                added: serde_json::from_value(
+                    value
+                        .get("added")
+                        .expect("This should be available")
+                        .to_owned(),
+                )
+                .expect("This should be parsable"),
+                removed: serde_json::from_value(
+                    value
+                        .get("removed")
+                        .expect("This should be available")
+                        .to_owned(),
+                )
+                .expect("This should be parsable"),
+            },
+            OperationType::SetStatus => Self::SetStatus {
+                timestamp: get! {value, "timestamp"},
+                status: get! {value, "status"},
+            },
+            OperationType::SetTitle => Self::SetTitle {
+                timestamp: get! {value, "timestamp"},
+                title: get! {value, "title"},
+                was: get! {value, "was"},
+            },
+            OperationType::NoOp => Self::NoOp {},
+            OperationType::SetMetadata => Self::SetMetadata {},
+        };
+
+        Ok(op)
+    }
+}
diff --git a/pkgs/by-name/ba/back/src/git_bug/issue/operation/operation_type.rs b/pkgs/by-name/ba/back/src/git_bug/issue/operation/operation_type.rs
new file mode 100644
index 0000000..69d272f
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/git_bug/issue/operation/operation_type.rs
@@ -0,0 +1,51 @@
+// Back - An extremely simple git issue tracking system. Inspired by tvix's
+// panettone
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of Back.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/agpl.txt>.
+
+pub enum OperationType {
+    AddComment,
+    Create,
+    EditComment,
+    LabelChange,
+    NoOp,
+    SetMetadata,
+    SetStatus,
+    SetTitle,
+}
+
+impl OperationType {
+    // NOTE(@bpeetz): This mapping should always be the same as `git_bug`'s.
+    // The mapping is defined in `git-bug/entities/bug/operation.go`. <2024-12-26>
+    pub fn to_json_int(self) -> u64 {
+        match self {
+            OperationType::Create => 1,
+            OperationType::SetTitle => 2,
+            OperationType::AddComment => 3,
+            OperationType::SetStatus => 4,
+            OperationType::LabelChange => 5,
+            OperationType::EditComment => 6,
+            OperationType::NoOp => 7,
+            OperationType::SetMetadata => 8,
+        }
+    }
+    pub fn from_json_int(value: u64) -> Self {
+        match value {
+            1 => OperationType::Create,
+            2 => OperationType::SetTitle,
+            3 => OperationType::AddComment,
+            4 => OperationType::SetStatus,
+            5 => OperationType::LabelChange,
+            6 => OperationType::EditComment,
+            7 => OperationType::NoOp,
+            8 => OperationType::SetMetadata,
+            other => unimplemented!("The operation type {other} is not recognized."),
+        }
+    }
+}
diff --git a/pkgs/by-name/ba/back/src/git_bug/mod.rs b/pkgs/by-name/ba/back/src/git_bug/mod.rs
new file mode 100644
index 0000000..c0a5372
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/git_bug/mod.rs
@@ -0,0 +1,28 @@
+// Back - An extremely simple git issue tracking system. Inspired by tvix's
+// panettone
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of Back.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/agpl.txt>.
+
+pub mod dag;
+pub mod format;
+pub mod issue;
+
+#[macro_export]
+macro_rules! get {
+    ($value:expr, $name:expr) => {
+        $value
+            .get($name)
+            .expect(concat!(
+                "Expected field ",
+                stringify!($name),
+                "to exists, but was missing."
+            ))
+            .into()
+    };
+}
diff --git a/pkgs/by-name/ba/back/src/main.rs b/pkgs/by-name/ba/back/src/main.rs
index e8f36d2..009bdb6 100644
--- a/pkgs/by-name/ba/back/src/main.rs
+++ b/pkgs/by-name/ba/back/src/main.rs
@@ -18,6 +18,7 @@ use crate::web::{closed, open, show_issue, styles};
 mod cli;
 pub mod config;
 mod error;
+pub mod git_bug;
 mod web;
 
 #[rocket::main]
diff --git a/pkgs/by-name/ba/back/src/web/issue/mod.rs b/pkgs/by-name/ba/back/src/web/issue/mod.rs
deleted file mode 100644
index 79ef70f..0000000
--- a/pkgs/by-name/ba/back/src/web/issue/mod.rs
+++ /dev/null
@@ -1,337 +0,0 @@
-// Back - An extremely simple git issue tracking system. Inspired by tvix's
-// panettone
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: AGPL-3.0-or-later
-//
-// This file is part of Back.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/agpl.txt>.
-
-use std::fmt::Display;
-
-use chrono::DateTime;
-use gix::{bstr::ByteSlice, Commit, Id, ObjectId, Repository};
-use raw::{Operation, RawIssue};
-use rocket::response::content::RawHtml;
-
-use crate::config::BackConfig;
-
-use super::format::{BackString, Markdown};
-
-mod raw;
-
-#[derive(Debug, Default)]
-pub struct TimeStamp {
-    value: u64,
-}
-impl TimeStamp {
-    pub fn new(val: u64) -> Self {
-        Self { value: val }
-    }
-}
-impl Display for TimeStamp {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let date =
-            DateTime::from_timestamp(self.value as i64, 0).expect("This timestamp should be vaild");
-
-        let newdate = date.format("%Y-%m-%d %H:%M:%S");
-        f.write_str(newdate.to_string().as_str())
-    }
-}
-
-#[derive(Debug)]
-pub struct Comment<'a> {
-    pub id: Id<'a>,
-    pub author: Author,
-    pub message: Markdown,
-    pub timestamp: TimeStamp,
-}
-
-#[derive(Debug, Default)]
-pub struct Author {
-    name: BackString,
-    email: BackString,
-}
-
-#[derive(Debug)]
-pub struct IssueId<'a> {
-    value: Id<'a>,
-}
-impl<'a> IssueId<'a> {
-    pub fn new(id: Id<'a>) -> Self {
-        Self { value: id }
-    }
-}
-impl Display for IssueId<'_> {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let shortend = self.value.shorten().expect("This should work.");
-        f.write_str(shortend.to_string().as_str())
-    }
-}
-
-#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone)]
-pub enum Status {
-    #[default]
-    Open,
-    Closed,
-}
-impl Display for Status {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Status::Open => f.write_str("Open"),
-            Status::Closed => f.write_str("Closed"),
-        }
-    }
-}
-
-#[derive(Debug)]
-pub struct Issue<'a> {
-    pub id: IssueId<'a>,
-    pub author: Author,
-    pub timestamp: TimeStamp,
-    pub title: Markdown,
-    pub message: Markdown,
-    pub comments: Vec<Comment<'a>>,
-    pub status: Status,
-    pub last_status_change: Option<TimeStamp>,
-}
-impl<'a> Issue<'a> {
-    pub fn default_with_id(id: Id<'a>) -> Self {
-        Self {
-            id: IssueId::new(id),
-            author: Author::default(),
-            timestamp: TimeStamp::default(),
-            title: Markdown::default(),
-            message: Markdown::default(),
-            comments: <Vec<Comment>>::default(),
-            status: Status::default(),
-            last_status_change: <Option<TimeStamp>>::default(),
-        }
-    }
-
-    pub fn from_commit_id(repo: &'a Repository, commit_id: ObjectId) -> Self {
-        fn unwrap_id<'b>(repo: &Repository, id: &Commit<'b>) -> (RawIssue, Id<'b>) {
-            let tree_obj = repo
-                .find_object(
-                    id.tree_id()
-                        .expect("All of git-bug's commits should have trees attached to them'"),
-                )
-                .expect("The object with this id should exist.")
-                .try_into_tree()
-                .expect("The git-bug's data model enforces this.");
-
-            let ops_ref = tree_obj
-                .find_entry("ops")
-                .expect("All of git-bug's trees should contain a 'ops' json file");
-
-            let issue_data = repo
-                .find_object(ops_ref.object_id())
-                .expect("The object with this id should exist.")
-                .try_into_blob()
-                .expect("The git-bug's data model enforces this.")
-                .data
-                .clone();
-
-            let raw_issue = serde_json::from_str(
-                issue_data
-                    .to_str()
-                    .expect("git-bug's ensures, that this is valid json."),
-            )
-            .expect("The returned json should be valid");
-
-            (raw_issue, id.id())
-        }
-
-        let commit_obj = repo
-            .find_object(commit_id)
-            .expect("The object with this id should exist.")
-            .try_into_commit()
-            .expect("The git-bug's data model enforces this.");
-
-        let mut issues = vec![unwrap_id(repo, &commit_obj)];
-
-        let mut current_commit_obj = commit_obj;
-        while current_commit_obj.parent_ids().count() != 0 {
-            assert_eq!(
-                current_commit_obj.parent_ids().count(),
-                1,
-                "There should be only one parent"
-            );
-            let parent = current_commit_obj
-                .parent_ids()
-                .last()
-                .expect("One does exist");
-
-            let parent_id = parent.object().expect("The object exists").id;
-            let parent_commit = repo
-                .find_object(parent_id)
-                .expect("This is a valid id")
-                .try_into_commit()
-                .expect("This should be a commit");
-
-            issues.push(unwrap_id(repo, &parent_commit));
-            current_commit_obj = parent_commit;
-        }
-
-        let mut final_issue = Self::default_with_id(current_commit_obj.id());
-        for (issue, id) in issues {
-            for op in issue.operations {
-                match op {
-                    Operation::AddComment { timestamp, message } => {
-                        final_issue.comments.push(Comment {
-                            id,
-                            author: issue.author.load_identity(repo),
-                            message: Markdown::from(message),
-                            timestamp: TimeStamp::new(timestamp),
-                        })
-                    }
-                    Operation::Create {
-                        timestamp,
-                        title,
-                        message,
-                    } => {
-                        final_issue.author = issue.author.load_identity(repo);
-                        final_issue.title = Markdown::from(title);
-                        final_issue.message = Markdown::from(message);
-                        final_issue.timestamp = TimeStamp::new(timestamp);
-                    }
-                    Operation::SetStatus { timestamp, status } => {
-                        final_issue.status = status;
-                        final_issue.last_status_change = Some(TimeStamp::new(timestamp));
-                    }
-                }
-            }
-        }
-        final_issue
-    }
-
-    pub fn to_list_entry(&self) -> RawHtml<String> {
-        let comment_list = if self.comments.is_empty() {
-            String::new()
-        } else {
-            format!(
-                r#"
-                <span class="comment-count"> - {} comments</span>
-            "#,
-                self.comments.len()
-            )
-        };
-        let Issue {
-            id,
-            title,
-            message: _,
-            author,
-            timestamp,
-            comments: _,
-            status: _,
-            last_status_change: _,
-        } = self;
-        let Author { name, email } = author;
-        RawHtml(format!(
-            r#"
-               <li>
-                  <a href="/issue/{id}">
-                     <p>
-                        <span class="issue-subject">{title}</span>
-                     </p>
-                     <span class="issue-number">{id}</span> - <span class="created-by-at">Opened by <span class="user-name">{name}</span> <span class="user-email">&lt;{email}&gt;</span> at <span class="timestamp">{timestamp}</span></span>{comment_list}                  </a>
-               </li>
-"#,
-        ))
-    }
-
-    pub fn to_html(&self, config: &BackConfig) -> RawHtml<String> {
-        let fmt_comments: String = self
-            .comments
-            .iter()
-            .map(|val| {
-                let Comment {
-                    id,
-                    author,
-                    message,
-                    timestamp,
-                } = val;
-                let Author { name, email: _ } = author;
-
-                format!(
-                    r#"
-               <li class="comment" id="{id}">
-                  {message}
-                  <p class="comment-info"><span class="user-name">{name} at {timestamp}</span></p>
-               </li>
-                "#,
-                )
-            })
-            .collect::<Vec<String>>()
-            .join("\n");
-
-        let maybe_comments = if fmt_comments.is_empty() {
-            String::new()
-        } else {
-            format!(
-                r#"
-            <ol class="issue-history">
-            {fmt_comments}
-            </ol>
-            "#
-            )
-        };
-
-        {
-            let Issue {
-                id,
-                title,
-                message,
-                author,
-                timestamp,
-                comments: _,
-                status: _,
-                last_status_change: _,
-            } = self;
-            let Author { name, email } = author;
-            let html_title = BackString::from(title.clone());
-
-            RawHtml(format!(
-                r#"
-<!DOCTYPE html>
-<html lang="en">
-   <head>
-      <title>{html_title} | Back</title>
-      <link href="/style.css" rel="stylesheet" type="text/css">
-      <meta content="width=device-width,initial-scale=1" name="viewport">
-   </head>
-   <body>
-      <div class="content">
-         <nav>
-         <a href="/issues/open">Open Issues</a>
-         <a href="/issues/closed">Closed Issues</a>
-         </nav>
-         <header>
-            <h1>{title}</h1>
-            <div class="issue-number">{id}</div>
-         </header>
-         <main>
-            <div class="issue-info">
-                <span class="created-by-at">Opened by <span class="user-name">{name}</span> <span class="user-email">&lt;{email}&gt;</span> at <span class="timestamp">{timestamp}</span></span>
-            </div>
-            {message}
-            {maybe_comments}
-         </main>
-         <footer>
-            <nav>
-            <a href="/issues/open">Open Issues</a>
-            <a href="{}">Source code</a>
-            <a href="/issues/closed">Closed Issues</a>
-            </nav>
-         </footer>
-      </div>
-   </body>
-</html>
-"#,
-                config.source_code_repository_url
-            ))
-        }
-    }
-}
diff --git a/pkgs/by-name/ba/back/src/web/issue/raw.rs b/pkgs/by-name/ba/back/src/web/issue/raw.rs
deleted file mode 100644
index bb447ec..0000000
--- a/pkgs/by-name/ba/back/src/web/issue/raw.rs
+++ /dev/null
@@ -1,145 +0,0 @@
-// Back - An extremely simple git issue tracking system. Inspired by tvix's
-// panettone
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: AGPL-3.0-or-later
-//
-// This file is part of Back.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/agpl.txt>.
-
-use gix::{bstr::ByteSlice, Repository};
-use serde::Deserialize;
-use serde_json::Value;
-
-use crate::web::format::BackString;
-
-use super::{Author, Status};
-
-macro_rules! get {
-    ($value:expr, $name:expr, $type_fun:ident) => {
-        $value
-            .get($name)
-            .expect(concat!(
-                "Expected field ",
-                stringify!($name),
-                "to exists, but was missing."
-            ))
-            .$type_fun()
-            .expect(concat!(
-                "Failed to interpret field ",
-                stringify!($name),
-                " as ",
-                stringify!($type),
-                "!"
-            ))
-    };
-}
-
-#[derive(Deserialize)]
-pub(super) struct RawIssue {
-    pub(super) author: RawAuthor,
-
-    #[serde(alias = "ops")]
-    pub(super) operations: Vec<Operation>,
-}
-
-#[derive(Deserialize, Clone, Default, Debug)]
-pub(super) struct RawAuthor {
-    id: String,
-}
-
-impl RawAuthor {
-    pub fn load_identity(&self, repo: &Repository) -> Author {
-        let commit_obj = repo
-            .find_reference(&format!("refs/identities/{}", self.id))
-            .expect("All authors should also have identities")
-            .peel_to_commit()
-            .expect("All identities should be commits");
-        let tree_obj = repo
-            .find_tree(
-                commit_obj
-                    .tree()
-                    .expect("The commit should have an tree associated with it")
-                    .id,
-            )
-            .expect("This should be a tree");
-        let data = repo
-            .find_blob(
-                tree_obj
-                    .find_entry("version")
-                    .expect("This entry should exist")
-                    .object()
-                    .expect("This should point to a blob entry")
-                    .id,
-            )
-            .expect("This blob should exist")
-            .data
-            .clone();
-
-        let json: Value = serde_json::from_str(data.to_str().expect("This is encoded json"))
-            .expect("This is valid json");
-
-        Author {
-            name: BackString::from(get! {json, "name", as_str}.to_owned()),
-            email: BackString::from(get! {json, "email", as_str}.to_owned()),
-        }
-    }
-}
-
-#[derive(Deserialize)]
-#[serde(from = "Value")]
-pub(super) enum Operation {
-    AddComment {
-        timestamp: u64,
-        message: String,
-        // files: Option<String>, TODO
-    },
-    SetStatus {
-        timestamp: u64,
-        status: Status,
-    },
-    Create {
-        timestamp: u64,
-        title: String,
-        message: String,
-        // files: Option<String>, TODO
-    },
-}
-
-impl From<u64> for Status {
-    fn from(value: u64) -> Self {
-        match value {
-            1 => Status::Open,
-            2 => Status::Closed,
-            other => todo!("The status ({other}) is not yet implemented."),
-        }
-    }
-}
-
-impl From<Value> for Operation {
-    fn from(value: Value) -> Self {
-        match value
-            .get("type")
-            .expect("Should exist")
-            .as_u64()
-            .expect("This should work")
-        {
-            1 => Self::Create {
-                title: get! {value, "title", as_str}.to_owned(),
-                message: get! {value, "message", as_str}.to_owned(),
-                timestamp: get! {value, "timestamp", as_u64},
-            },
-            3 => Self::AddComment {
-                message: get! {value, "message", as_str}.to_owned(),
-                timestamp: get! {value, "timestamp", as_u64},
-            },
-            4 => Self::SetStatus {
-                status: Status::from(get! {value, "status", as_u64}),
-                timestamp: get! {value, "timestamp", as_u64},
-            },
-            other => todo!("The type ({other}) is not yet added as a a valid operation. It's value is: '{value}''"),
-        }
-    }
-}
diff --git a/pkgs/by-name/ba/back/src/web/issue_html.rs b/pkgs/by-name/ba/back/src/web/issue_html.rs
new file mode 100644
index 0000000..45c0281
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/web/issue_html.rs
@@ -0,0 +1,166 @@
+// Back - An extremely simple git issue tracking system. Inspired by tvix's
+// panettone
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This file is part of Back.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/agpl.txt>.
+
+use rocket::response::content::RawHtml;
+
+use crate::{
+    config::BackConfig,
+    git_bug::{
+        format::HtmlString,
+        issue::{identity::Author, CollapsedIssue, Comment},
+    },
+};
+
+impl CollapsedIssue {
+    pub fn to_list_entry(&self) -> RawHtml<String> {
+        let comment_list = if self.comments.is_empty() {
+            String::new()
+        } else {
+            let comments_string = if self.comments.len() > 1 {
+                "comments"
+            } else {
+                "comment"
+            };
+
+            format!(
+                r#"
+                <span class="comment-count"> - {} {}</span>
+            "#,
+                self.comments.len(),
+                comments_string
+            )
+        };
+
+        let CollapsedIssue {
+            id,
+            title,
+            message: _,
+            author,
+            timestamp,
+            comments: _,
+            status: _,
+            last_status_change: _,
+            labels: _,
+        } = self;
+
+        let Author { name, email, id: _ } = author;
+
+        RawHtml(format!(
+            r#"
+               <li>
+                  <a href="/issue/{id}">
+                     <p>
+                        <span class="issue-subject">{title}</span>
+                     </p>
+                     <span class="issue-number">{id}</span> - <span class="created-by-at">Opened by <span class="user-name">{name}</span> <span class="user-email">&lt;{email}&gt;</span> at <span class="timestamp">{timestamp}</span></span>{comment_list}                  </a>
+               </li>
+"#,
+        ))
+    }
+
+    pub fn to_html(&self, config: &BackConfig) -> RawHtml<String> {
+        let comments = if self.comments.is_empty() {
+            String::new()
+        } else {
+            let fmt_comments: String = self
+                .comments
+                .iter()
+                .map(|val| {
+                    let Comment {
+                        id,
+                        author,
+                        message,
+                        timestamp,
+                    } = val;
+                    let Author {
+                        name,
+                        email: _,
+                        id: _,
+                    } = author;
+
+                    format!(
+                        r#"
+               <li class="comment" id="{id}">
+                  {message}
+                  <p class="comment-info"><span class="user-name">{name} at {timestamp}</span></p>
+               </li>
+                "#,
+                    )
+                })
+                .collect::<Vec<String>>()
+                .join("\n");
+
+            format!(
+                r#"
+            <ol class="issue-history">
+            {fmt_comments}
+            </ol>
+            "#
+            )
+        };
+
+        {
+            let CollapsedIssue {
+                id,
+                title,
+                message,
+                author,
+                timestamp,
+                comments: _,
+                status: _,
+                last_status_change: _,
+                labels: _,
+            } = self;
+            let Author { name, email, id: _ } = author;
+            let html_title = HtmlString::from(title.clone());
+
+            RawHtml(format!(
+                r#"
+<!DOCTYPE html>
+<html lang="en">
+   <head>
+      <title>{html_title} | Back</title>
+      <link href="/style.css" rel="stylesheet" type="text/css">
+      <meta content="width=device-width,initial-scale=1" name="viewport">
+   </head>
+   <body>
+      <div class="content">
+         <nav>
+         <a href="/issues/open">Open Issues</a>
+         <a href="/issues/closed">Closed Issues</a>
+         </nav>
+         <header>
+            <h1>{title}</h1>
+            <div class="issue-number">{id}</div>
+         </header>
+         <main>
+            <div class="issue-info">
+                <span class="created-by-at">Opened by <span class="user-name">{name}</span> <span class="user-email">&lt;{email}&gt;</span> at <span class="timestamp">{timestamp}</span></span>
+            </div>
+            {message}
+            {comments}
+         </main>
+         <footer>
+            <nav>
+            <a href="/issues/open">Open Issues</a>
+            <a href="{}">Source code</a>
+            <a href="/issues/closed">Closed Issues</a>
+            </nav>
+         </footer>
+      </div>
+   </body>
+</html>
+"#,
+                config.source_code_repository_url
+            ))
+        }
+    }
+}
diff --git a/pkgs/by-name/ba/back/src/web/mod.rs b/pkgs/by-name/ba/back/src/web/mod.rs
index ed91e7e..1e6a5af 100644
--- a/pkgs/by-name/ba/back/src/web/mod.rs
+++ b/pkgs/by-name/ba/back/src/web/mod.rs
@@ -9,125 +9,121 @@
 // You should have received a copy of the License along with this program.
 // If not, see <https://www.gnu.org/licenses/agpl.txt>.
 
-use std::path::Path;
-
 use crate::{
     config::BackConfig,
-    web::issue::{Issue, Status},
+    error::{self, Error},
+    git_bug::{
+        dag::issues_from_repository,
+        issue::{CollapsedIssue, Status},
+    },
 };
-use gix::{refs::Target, Repository};
-use issue_show::BackPrefix;
+use prefix::BackPrefix;
 use rocket::{
     get,
     response::content::{RawCss, RawHtml},
     State,
 };
 
-mod format;
-mod issue;
-mod issue_show;
+mod issue_html;
+pub mod prefix;
 
 #[get("/style.css")]
 pub fn styles() -> RawCss<String> {
     RawCss(include_str!("../../assets/style.css").to_owned())
 }
 
-fn list_all_issues(repo: &'_ Repository) -> Vec<Issue<'_>> {
-    repo.refs
-        .iter()
-        .expect("We should be able to iterate over references")
-        .prefixed(Path::new("refs/bugs/"))
-        .expect("The 'refs/bugs/' namespace should exist")
-        .map(|val| {
-            let reference = val.expect("'val' should be an object?");
-            if let Target::Object(commit_id) = reference.target {
-                Issue::from_commit_id(repo, commit_id)
-            } else {
-                unreachable!("All 'refs/bugs/' should contain a clear target.");
-            }
-        })
-        .collect()
-}
-
 pub fn issue_list_boilerplate(
     config: &State<BackConfig>,
     wanted_status: Status,
     counter_status: Status,
-) -> RawHtml<String> {
+) -> error::Result<RawHtml<String>> {
     let repository = &config.repository;
 
-    let issue_list = list_all_issues(&repository.to_thread_local())
-        .iter()
+    let issue_list = issues_from_repository(&repository.to_thread_local())?
+        .into_iter()
         .fold(String::new(), |acc, val| {
-            format!("{}{}", acc, &issue_to_string(val, wanted_status).0)
+            let issue = val.collaps();
+
+            format!("{}{}", acc, {
+                if issue.status == wanted_status {
+                    let issue_entry = issue.to_list_entry();
+                    issue_entry.0
+                } else {
+                    String::new()
+                }
+            })
         });
 
     let counter_status_lower = counter_status.to_string().to_lowercase();
-    RawHtml(format!(
+    Ok(RawHtml(format!(
         r#"
-<!DOCTYPE html>
-<html lang="en">
-   <head>
-      <title>Back</title>
-      <link href="/style.css" rel="stylesheet" type="text/css">
-      <meta content="width=device-width,initial-scale=1" name="viewport">
-   </head>
-   <body>
-      <div class="content">
-         <header>
-            <h1>{wanted_status} Issues</h1>
-         </header>
-         <main>
-            <div class="issue-links">
-               <a href="/issues/{counter_status_lower}/">View {counter_status} issues</a>
-               <a href="{}">Source code</a>
-               <!--
-               <form class="issue-search" method="get">
-                   <input name="search" title="Issue search query" type="search">
-                   <input class="sr-only" type="submit" value="Search Issues">
-               </form>
-               -->
-            </div>
-            <ol class="issue-list">
-            {issue_list}
-            </ol>
-         </main>
-      </div>
-   </body>
-</html>
-"#,
+    <!DOCTYPE html>
+    <html lang="en">
+       <head>
+          <title>Back</title>
+          <link href="/style.css" rel="stylesheet" type="text/css">
+          <meta content="width=device-width,initial-scale=1" name="viewport">
+       </head>
+       <body>
+          <div class="content">
+             <header>
+                <h1>{wanted_status} Issues</h1>
+             </header>
+             <main>
+                <div class="issue-links">
+                   <a href="/issues/{counter_status_lower}/">View {counter_status} issues</a>
+                   <a href="{}">Source code</a>
+                   <!--
+                   <form class="issue-search" method="get">
+                       <input name="search" title="Issue search query" type="search">
+                       <input class="sr-only" type="submit" value="Search Issues">
+                   </form>
+                   -->
+                </div>
+                <ol class="issue-list">
+                {issue_list}
+                </ol>
+             </main>
+          </div>
+       </body>
+    </html>
+    "#,
         config.source_code_repository_url
-    ))
+    )))
 }
 
 #[get("/issues/open")]
-pub fn open(config: &State<BackConfig>) -> RawHtml<String> {
+pub fn open(config: &State<BackConfig>) -> error::Result<RawHtml<String>> {
     issue_list_boilerplate(config, Status::Open, Status::Closed)
 }
 #[get("/issues/closed")]
-pub fn closed(config: &State<BackConfig>) -> RawHtml<String> {
+pub fn closed(config: &State<BackConfig>) -> error::Result<RawHtml<String>> {
     issue_list_boilerplate(config, Status::Closed, Status::Open)
 }
 
 #[get("/issue/<prefix>")]
-pub fn show_issue(config: &State<BackConfig>, prefix: BackPrefix) -> RawHtml<String> {
+pub fn show_issue(
+    config: &State<BackConfig>,
+    prefix: Result<BackPrefix, gix::hash::prefix::from_hex::Error>,
+) -> error::Result<RawHtml<String>> {
+    // NOTE(@bpeetz): Explicitly unwrap the `prefix` here (instead of taking the unwrapped value as
+    // argument), to avoid triggering rockets "errors forward to the next route" feature.
+    // This ensures, that our error message actually reaches the user. <2024-12-26>
+    let prefix = prefix?;
+
     let repository = config.repository.to_thread_local();
 
-    let all_issues = list_all_issues(&repository);
+    let all_issues: Vec<CollapsedIssue> = issues_from_repository(&repository)?
+        .into_iter()
+        .map(|val| val.collapse())
+        .collect();
+
     let maybe_issue = all_issues
         .iter()
         .find(|issue| issue.id.to_string().starts_with(&prefix.to_string()));
 
     match maybe_issue {
-        Some(issue) => issue.to_html(config),
-        None => RawHtml(format!("Issue with id '{prefix}' not found!")),
-    }
-}
-
-fn issue_to_string(issue: &Issue<'_>, status: Status) -> RawHtml<String> {
-    if issue.status == status {
-        issue.to_list_entry()
-    } else {
-        RawHtml(String::default())
+        Some(issue) => Ok(issue.to_html(config)),
+        None => Err(Error::IssuesPrefixMissing { prefix }),
     }
 }
diff --git a/pkgs/by-name/ba/back/src/web/issue_show.rs b/pkgs/by-name/ba/back/src/web/prefix.rs
index 638840e..5143799 100644
--- a/pkgs/by-name/ba/back/src/web/issue_show.rs
+++ b/pkgs/by-name/ba/back/src/web/prefix.rs
@@ -14,6 +14,7 @@ use std::fmt::Display;
 use gix::hash::Prefix;
 use rocket::request::FromParam;
 
+#[derive(Debug)]
 pub struct BackPrefix {
     prefix: Prefix,
 }