summary refs log tree commit diff stats
path: root/pkgs/by-name/ba/back/src/issues/issue
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/by-name/ba/back/src/issues/issue')
-rw-r--r--pkgs/by-name/ba/back/src/issues/issue/mod.rs328
-rw-r--r--pkgs/by-name/ba/back/src/issues/issue/raw.rs145
2 files changed, 473 insertions, 0 deletions
diff --git a/pkgs/by-name/ba/back/src/issues/issue/mod.rs b/pkgs/by-name/ba/back/src/issues/issue/mod.rs
new file mode 100644
index 0000000..ada7593
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/issues/issue/mod.rs
@@ -0,0 +1,328 @@
+// 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 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().unwrap())
+                .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").unwrap();
+
+            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) -> 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="/issues/closed">Closed Issues</a>
+            </nav>
+         </footer>
+      </div>
+   </body>
+</html>
+"#,
+            ))
+        }
+    }
+}
diff --git a/pkgs/by-name/ba/back/src/issues/issue/raw.rs b/pkgs/by-name/ba/back/src/issues/issue/raw.rs
new file mode 100644
index 0000000..48d2a9f
--- /dev/null
+++ b/pkgs/by-name/ba/back/src/issues/issue/raw.rs
@@ -0,0 +1,145 @@
+// 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::issues::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}''"),
+        }
+    }
+}