diff options
Diffstat (limited to 'pkgs/by-name/ba/back/src')
21 files changed, 1455 insertions, 753 deletions
diff --git a/pkgs/by-name/ba/back/src/cli.rs b/pkgs/by-name/ba/back/src/cli.rs new file mode 100644 index 0000000..79f0d63 --- /dev/null +++ b/pkgs/by-name/ba/back/src/cli.rs @@ -0,0 +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::path::PathBuf; + +use clap::Parser; + +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +#[allow(clippy::module_name_repetitions)] +/// An extremely simple git issue tracking system. +/// Inspired by tvix's panettone +pub struct Cli { + /// The path to the configuration file. The file should be written in JSON. + pub config_file: PathBuf, +} diff --git a/pkgs/by-name/ba/back/src/config/mod.rs b/pkgs/by-name/ba/back/src/config/mod.rs new file mode 100644 index 0000000..a680b90 --- /dev/null +++ b/pkgs/by-name/ba/back/src/config/mod.rs @@ -0,0 +1,69 @@ +// 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::{ + fs, + path::{Path, PathBuf}, +}; + +use gix::ThreadSafeRepository; +use serde::Deserialize; +use url::Url; + +use crate::error::{self, Error}; + +pub struct BackConfig { + // NOTE(@bpeetz): We do not need to html escape this, as the value must be a valid url. As such + // `<tags>` of all kinds _should_ be invalid. <2024-12-26> + pub source_code_repository_url: Url, + pub repository: ThreadSafeRepository, +} + +#[derive(Deserialize)] +struct RawBackConfig { + source_code_repository_url: Url, + repository_path: PathBuf, +} + +impl BackConfig { + pub fn from_config_file(path: &Path) -> error::Result<Self> { + let value = fs::read_to_string(path).map_err(|err| Error::ConfigRead { + file: path.to_owned(), + error: err, + })?; + + let raw: RawBackConfig = + serde_json::from_str(&value).map_err(|err| Error::ConfigParse { + file: path.to_owned(), + error: err, + })?; + + Self::try_from(raw) + } +} + +impl TryFrom<RawBackConfig> for BackConfig { + type Error = error::Error; + + fn try_from(value: RawBackConfig) -> Result<Self, Self::Error> { + let repository = { + ThreadSafeRepository::open(&value.repository_path).map_err(|err| Error::RepoOpen { + repository_path: value.repository_path, + error: Box::new(err), + }) + }?; + + Ok(Self { + repository, + source_code_repository_url: value.source_code_repository_url, + }) + } +} diff --git a/pkgs/by-name/ba/back/src/error/mod.rs b/pkgs/by-name/ba/back/src/error/mod.rs new file mode 100644 index 0000000..8b71700 --- /dev/null +++ b/pkgs/by-name/ba/back/src/error/mod.rs @@ -0,0 +1,94 @@ +// 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 { + file: PathBuf, + error: serde_json::Error, + }, + ConfigRead { + file: PathBuf, + error: io::Error, + }, + RocketLaunch(#[from] rocket::Error), + + RepoOpen { + repository_path: PathBuf, + error: Box<gix::open::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 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::ConfigParse { file, error } => { + write!( + f, + "while trying to parse the config file ({}): {error}", + file.display() + ) + } + Error::ConfigRead { file, error } => { + write!( + f, + "while trying to read the config file ({}): {error}", + file.display() + ) + } + Error::RocketLaunch(error) => { + write!(f, "while trying to start back: {error}") + } + Error::RepoOpen { + repository_path, + error, + } => { + write!( + f, + "while trying to open the repository ({}): {error}", + repository_path.display() + ) + } + 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, ¤t_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..b3b6bcc --- /dev/null +++ b/pkgs/by-name/ba/back/src/git_bug/format/mod.rs @@ -0,0 +1,144 @@ +// 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()) + } +} + +/// An UNIX time stamp. +/// +/// These should only ever be used for human-display, because timestamps are unreliably in a +/// distributed system. +/// +/// This one allows underlying access to it's value and is only obtainable via `unsafe` code. +/// The reason behind this is, that you might need to access this to improve the display for humans +/// (i.e., sorting by date). +#[derive(Debug, Default, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)] +pub struct UnsafeTimeStamp { + value: u64, +} +impl TimeStamp { + /// # Safety + /// This is not really unsafe, but there is just no way your can trust a time stamp in a + /// distributed system. As such access to the raw value could lead to bugs. + pub unsafe fn to_unsafe(self) -> UnsafeTimeStamp { + UnsafeTimeStamp { value: self.value } + } +} + +#[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 +/// & --> & +/// < --> < +/// > --> > +/// " --> " +/// ' --> ' ' is not recommended +/// / --> / 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("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '"' => output.push_str("""), + '\'' => output.push_str("'"), + '/' => output.push_str("/"), + _ => 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() + }; +} diff --git a/pkgs/by-name/ba/back/src/issues/format/mod.rs b/pkgs/by-name/ba/back/src/issues/format/mod.rs deleted file mode 100644 index f78d3b3..0000000 --- a/pkgs/by-name/ba/back/src/issues/format/mod.rs +++ /dev/null @@ -1,88 +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 markdown::to_html; - -#[derive(Debug, Default, Clone)] -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()) - } -} - -#[derive(Debug, Default)] -pub struct BackString { - value: String, -} - -impl From<Markdown> for BackString { - fn from(value: Markdown) -> Self { - Self { value: value.value } - } -} - -impl From<String> for BackString { - fn from(value: String) -> Self { - Self { value } - } -} -impl Display for BackString { - 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 -/// & --> & -/// < --> < -/// > --> > -/// " --> " -/// ' --> ' ' is not recommended -/// / --> / 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("&"), - '<' => output.push_str("<"), - '>' => output.push_str(">"), - '"' => output.push_str("""), - '\'' => output.push_str("'"), - '/' => output.push_str("/"), - _ => output.push(c), - } - } - - // Not using shrink_to_fit() on purpose - output -} diff --git a/pkgs/by-name/ba/back/src/issues/issue/mod.rs b/pkgs/by-name/ba/back/src/issues/issue/mod.rs deleted file mode 100644 index b78f473..0000000 --- a/pkgs/by-name/ba/back/src/issues/issue/mod.rs +++ /dev/null @@ -1,332 +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::SOURCE_CODE_REPOSITORY; - -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"><{email}></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"><{email}></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> -"#, - SOURCE_CODE_REPOSITORY.get().expect("This should be set") - )) - } - } -} diff --git a/pkgs/by-name/ba/back/src/issues/issue/raw.rs b/pkgs/by-name/ba/back/src/issues/issue/raw.rs deleted file mode 100644 index 48d2a9f..0000000 --- a/pkgs/by-name/ba/back/src/issues/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::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}''"), - } - } -} diff --git a/pkgs/by-name/ba/back/src/issues/mod.rs b/pkgs/by-name/ba/back/src/issues/mod.rs deleted file mode 100644 index 744d5ba..0000000 --- a/pkgs/by-name/ba/back/src/issues/mod.rs +++ /dev/null @@ -1,134 +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::path::Path; - -use crate::{ - issues::issue::{Issue, Status}, - SOURCE_CODE_REPOSITORY, -}; -use format::BackString; -use gix::{refs::Target, Repository}; -use issue_show::BackPrefix; -use rocket::{ - get, - response::content::{RawCss, RawHtml}, -}; - -use crate::REPOSITORY; - -mod format; -mod issue; -mod issue_show; - -#[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(wanted_status: Status, counter_status: Status) -> RawHtml<String> { - let repository = REPOSITORY.get().expect("This should have been set"); - - let issue_list = list_all_issues(&repository.to_thread_local()) - .iter() - .fold(String::new(), |acc, val| { - format!("{}{}", acc, &issue_to_string(val, wanted_status).0) - }); - - let counter_status_lower = counter_status.to_string().to_lowercase(); - 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> -"#, - SOURCE_CODE_REPOSITORY.get().expect("This should be set") - )) -} - -#[get("/issues/open")] -pub fn open() -> RawHtml<String> { - issue_list_boilerplate(Status::Open, Status::Closed) -} -#[get("/issues/closed")] -pub fn closed() -> RawHtml<String> { - issue_list_boilerplate(Status::Closed, Status::Open) -} - -#[get("/issue/<prefix>")] -pub fn show_issue(prefix: BackPrefix) -> RawHtml<String> { - let repository = REPOSITORY - .get() - .expect("This should have been set") - .to_thread_local(); - - let all_issues = list_all_issues(&repository); - 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(), - 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()) - } -} diff --git a/pkgs/by-name/ba/back/src/main.rs b/pkgs/by-name/ba/back/src/main.rs index 86fe196..b75737a 100644 --- a/pkgs/by-name/ba/back/src/main.rs +++ b/pkgs/by-name/ba/back/src/main.rs @@ -9,62 +9,43 @@ // 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::{env::args, path::PathBuf, process, sync::OnceLock}; - -use gix::ThreadSafeRepository; -use rocket::{launch, routes}; -use url::Url; - -use crate::issues::{closed, open, show_issue, styles}; - -mod issues; - -static REPOSITORY: OnceLock<ThreadSafeRepository> = OnceLock::new(); -static SOURCE_CODE_REPOSITORY: OnceLock<Url> = OnceLock::new(); +use std::process; + +use clap::Parser; +use config::BackConfig; +use rocket::routes; + +use crate::web::{closed, open, show_issue, styles}; + +mod cli; +pub mod config; +mod error; +pub mod git_bug; +mod web; + +fn main() -> Result<(), String> { + if let Err(err) = rocket_main() { + eprintln!("Error {err}"); + process::exit(1); + } else { + Ok(()) + } +} -#[launch] -fn rocket() -> _ { - let repository_path = { - let maybe_path = args().skip(1).rev().last(); - if let Some(path) = maybe_path { - PathBuf::from(path) - } else { - eprintln!("Usage: back <issue repoitory>"); - process::exit(1); - } - }; - let source_code_url = { - match std::env::var("BACK_SOURCE_CODE_REPOSITORY_URL") { - Ok(value) => match Url::parse(&value) { - Ok(url) => url, - Err(err) => { - eprintln!("Can't parse `BACK_SOURCE_CODE_REPOSITORY_URL` value as url: {err}"); - process::exit(1); - } - }, - Err(err) => { - eprintln!("back needs you to specify a source code repositiory as `BACK_SOURCE_CODE_REPOSITORY_URL`: {err}"); - process::exit(1); - } - } - }; +#[rocket::main] +async fn rocket_main() -> Result<(), error::Error> { + let args = cli::Cli::parse(); - SOURCE_CODE_REPOSITORY - .set(source_code_url) - .expect("This should be unset by this stage"); + let config = BackConfig::from_config_file(&args.config_file)?; - REPOSITORY - .set( - ThreadSafeRepository::open(&repository_path).unwrap_or_else(|err| { - eprintln!( - "Error while opening repository ('{}'): {} ", - repository_path.display(), - err - ); - process::exit(1); - }), - ) - .expect("There should be only one thread accessing this right now"); + rocket::build() + .mount("/", routes![open, closed, show_issue, styles]) + .manage(config) + .ignite() + .await + .expect("This error should only happen on a miss-configuration.") + .launch() + .await?; - rocket::build().mount("/", routes![open, closed, show_issue, styles]) + Ok(()) } 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"><{email}></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"><{email}></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 new file mode 100644 index 0000000..35dc59f --- /dev/null +++ b/pkgs/by-name/ba/back/src/web/mod.rs @@ -0,0 +1,134 @@ +// 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 crate::{ + config::BackConfig, + error::{self, Error}, + git_bug::{ + dag::issues_from_repository, + issue::{CollapsedIssue, Status}, + }, +}; +use prefix::BackPrefix; +use rocket::{ + get, + response::content::{RawCss, RawHtml}, + State, +}; + +mod issue_html; +pub mod prefix; + +#[get("/style.css")] +pub fn styles() -> RawCss<String> { + RawCss(include_str!("../../assets/style.css").to_owned()) +} + +pub fn issue_list_boilerplate( + config: &State<BackConfig>, + wanted_status: Status, + counter_status: Status, +) -> error::Result<RawHtml<String>> { + let repository = &config.repository; + + let mut issue_list = issues_from_repository(&repository.to_thread_local())? + .into_iter() + .map(|issue| issue.collapse()) + .collect::<Vec<CollapsedIssue>>(); + + // Sort by date descending. + issue_list.sort_by_key(|issue| unsafe { issue.timestamp.to_unsafe() }); + issue_list.reverse(); + + let issue_list_str = issue_list.into_iter().fold(String::new(), |acc, issue| { + 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(); + 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_str} + </ol> + </main> + </div> + </body> + </html> + "#, + config.source_code_repository_url + ))) +} + +#[get("/issues/open")] +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>) -> error::Result<RawHtml<String>> { + issue_list_boilerplate(config, Status::Closed, Status::Open) +} + +#[get("/issue/<prefix>")] +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: 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) => Ok(issue.to_html(config)), + None => Err(Error::IssuesPrefixMissing { prefix }), + } +} diff --git a/pkgs/by-name/ba/back/src/issues/issue_show.rs b/pkgs/by-name/ba/back/src/web/prefix.rs index 638840e..5143799 100644 --- a/pkgs/by-name/ba/back/src/issues/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, } |