summary refs log tree commit diff stats
path: root/pkgs/by-name/ba/back/src/git_bug
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2024-12-26 17:50:54 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2024-12-26 17:58:27 +0100
commitdfb5714045e99a09bf3f67890ae3cdeab47058b3 (patch)
tree5e76e310892ea7e36099312e25023aa154217a28 /pkgs/by-name/ba/back/src/git_bug
parentfix(pkgs/back): Use rocket to manage the configuration values (diff)
downloadnixos-server-dfb5714045e99a09bf3f67890ae3cdeab47058b3.zip
feat(pkgs/back): Rewrite the `git-bug` interface code
The previous code was more or less reverse engineered, whilst this code
is based on the actually git-bug source code. This improves the whole
issue and operation handling immensely and also makes the code better
maintainable. Furthermore, it also adds support for the operations that
had not already used in `vhack.eu/nixos-server.git`.
Diffstat (limited to 'pkgs/by-name/ba/back/src/git_bug')
-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.rs123
-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
9 files changed, 888 insertions, 0 deletions
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/git_bug/format/mod.rs b/pkgs/by-name/ba/back/src/git_bug/format/mod.rs
new file mode 100644
index 0000000..4ebf6d4
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/git_bug/format/mod.rs
@@ -0,0 +1,123 @@
+// 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 markdown::to_html;
+use serde::Deserialize;
+use serde_json::Value;
+
+#[derive(Debug, Default, Clone)]
+/// Markdown content.
+pub struct MarkDown {
+    value: String,
+}
+
+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 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, 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 HtmlString {
+    fn from(value: MarkDown) -> Self {
+        Self { value: value.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 HtmlString {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(escape_html(&self.value).as_str())
+    }
+}
+
+// From `tera::escape_html`
+/// Escape HTML following [OWASP](https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet)
+///
+/// Escape the following characters with HTML entity encoding to prevent switching
+/// into any execution context, such as script, style, or event handlers. Using
+/// hex entities is recommended in the spec. In addition to the 5 characters
+/// significant in XML (&, <, >, ", '), the forward slash is included as it helps
+/// to end an HTML entity.
+///
+/// ```text
+/// & --> &amp;
+/// < --> &lt;
+/// > --> &gt;
+/// " --> &quot;
+/// ' --> &#x27;     &apos; is not recommended
+/// / --> &#x2F;     forward slash is included as it helps end an HTML entity
+/// ```
+#[inline]
+pub fn escape_html(input: &str) -> String {
+    let mut output = String::with_capacity(input.len() * 2);
+    for c in input.chars() {
+        match c {
+            '&' => output.push_str("&amp;"),
+            '<' => output.push_str("&lt;"),
+            '>' => output.push_str("&gt;"),
+            '"' => output.push_str("&quot;"),
+            '\'' => output.push_str("&#x27;"),
+            '/' => output.push_str("&#x2F;"),
+            _ => output.push(c),
+        }
+    }
+
+    // Not using shrink_to_fit() on purpose
+    output
+}
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()
+    };
+}