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