// Back - An extremely simple git issue tracking system. Inspired by tvix's // panettone // // Copyright (C) 2024 Benedikt Peetz // 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 . 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>, pub status: Status, pub last_status_change: Option, } 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: >::default(), status: Status::default(), last_status_change: >::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 { let comment_list = if self.comments.is_empty() { String::new() } else { format!( r#" - {} comments "#, self.comments.len() ) }; let Issue { id, title, message: _, author, timestamp, comments: _, status: _, last_status_change: _, } = self; let Author { name, email } = author; RawHtml(format!( r#"
  • {title}

    {id} - Opened by {name} <{email}> at {timestamp}{comment_list}
  • "#, )) } pub fn to_html(&self) -> RawHtml { let fmt_comments: String = self .comments .iter() .map(|val| { let Comment { id, author, message, timestamp, } = val; let Author { name, email: _ } = author; format!( r#"
  • {message}

    {name} at {timestamp}

  • "#, ) }) .collect::>() .join("\n"); let maybe_comments = if fmt_comments.is_empty() { String::new() } else { format!( r#"
      {fmt_comments}
    "# ) }; { 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#" {html_title} | Back

    {title}

    {id}
    Opened by {name} <{email}> at {timestamp}
    {message} {maybe_comments}
    "#, )) } } }