aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cli.rs2
-rw-r--r--src/config/mod.rs66
-rw-r--r--src/error/mod.rs165
-rw-r--r--src/main.rs9
-rw-r--r--src/web/generate/mod.rs518
-rw-r--r--src/web/generate/templates.rs44
-rw-r--r--src/web/mod.rs123
-rw-r--r--src/web/responses.rs135
8 files changed, 759 insertions, 303 deletions
diff --git a/src/cli.rs b/src/cli.rs
index eeeed2b..f217b00 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -19,7 +19,7 @@ use clap::Parser;
#[allow(clippy::module_name_repetitions)]
/// An extremely simple git issue tracking system.
/// Inspired by tvix's panettone
-pub struct Cli {
+pub(crate) struct Cli {
/// The path to the configuration file. The file should be written in JSON.
pub config_file: PathBuf,
}
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 6c90fce..07c6c29 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -15,16 +15,13 @@ use std::{
path::{Path, PathBuf},
};
-use gix::ThreadSafeRepository;
+use git_bug::{entities::issue::Issue, replica::Replica};
use serde::Deserialize;
use url::Url;
-use crate::{
- error::{self, Error},
- git_bug::dag::is_git_bug,
-};
+use crate::error::{self, Error};
-#[derive(Deserialize)]
+#[derive(Debug, Deserialize)]
pub struct BackConfig {
/// The url to the source code of back. This is needed, because back is licensed under the
/// AGPL.
@@ -35,17 +32,24 @@ pub struct BackConfig {
/// `issues.foss-syndicate.org`
pub root_url: Url,
- project_list: PathBuf,
+ /// The file that list all the projects.
+ ///
+ /// This is `cgit`'s `project-list` setting.
+ pub project_list: PathBuf,
/// The path that is the common parent of all the repositories.
+ ///
+ /// This is `cgit`'s `scan-path` setting.
pub scan_path: PathBuf,
}
+#[derive(Debug)]
pub struct BackRepositories {
repositories: Vec<BackRepository>,
}
impl BackRepositories {
+ #[must_use]
pub fn iter(&self) -> <&Self as IntoIterator>::IntoIter {
self.into_iter()
}
@@ -62,23 +66,31 @@ impl<'a> IntoIterator for &'a BackRepositories {
impl BackRepositories {
/// Try to get the repository at path `path`.
- /// If no repository was registered/found at `path`, returns an error.
- pub fn get(&self, path: &Path) -> Result<&BackRepository, error::Error> {
+ ///
+ /// # Errors
+ /// - If no repository was registered/found at `path`
+ pub fn get(&self, path: &Path) -> Result<&BackRepository, Error> {
self.repositories
.iter()
.find(|p| p.repo_path == path)
- .ok_or(error::Error::RepoFind {
+ .ok_or(Error::RepoFind {
repository_path: path.to_owned(),
})
}
}
+#[derive(Debug)]
pub struct BackRepository {
repo_path: PathBuf,
}
impl BackRepository {
- pub fn open(&self, scan_path: &Path) -> Result<ThreadSafeRepository, error::Error> {
+ /// Open this repository.
+ ///
+ /// # Errors
+ ///
+ /// This function will return an error if the repository could not be opened.
+ pub fn open(&self, scan_path: &Path) -> Result<Replica, Error> {
let path = {
let base = scan_path.join(&self.repo_path);
if base.is_dir() {
@@ -87,30 +99,43 @@ impl BackRepository {
PathBuf::from(base.display().to_string() + ".git")
}
};
- let repo = ThreadSafeRepository::open(path).map_err(|err| Error::RepoOpen {
- repository_path: self.repo_path.to_owned(),
+ let repo = Replica::from_path(path).map_err(|err| Error::RepoOpen {
+ repository_path: self.repo_path.clone(),
error: Box::new(err),
})?;
- if is_git_bug(&repo.to_thread_local())? {
+
+ // We could also check that Identity data is in the Replica,
+ // but Issue existent is paramount.
+ if repo.contains::<Issue>()? {
Ok(repo)
} else {
- Err(error::Error::NotGitBug {
+ Err(Error::NotGitBug {
path: self.repo_path.clone(),
})
}
}
+ #[must_use]
pub fn path(&self) -> &Path {
&self.repo_path
}
}
impl BackConfig {
+ /// Returns the repositories of this [`BackConfig`].
+ ///
+ /// # Note
+ /// This will always re-read the `projects.list` file, to pick up repositories that were added
+ /// in the mean time.
+ ///
+ /// # Errors
+ ///
+ /// This function will return an error if the associated IO operations fail.
pub fn repositories(&self) -> error::Result<BackRepositories> {
let repositories = fs::read_to_string(&self.project_list)
- .map_err(|err| error::Error::ProjectListRead {
+ .map_err(|err| Error::ProjectListRead {
error: err,
- file: self.project_list.to_owned(),
+ file: self.project_list.clone(),
})?
.lines()
.try_fold(vec![], |mut acc, path| {
@@ -118,11 +143,16 @@ impl BackConfig {
repo_path: PathBuf::from(path),
});
- Ok::<_, error::Error>(acc)
+ Ok::<_, Error>(acc)
})?;
Ok(BackRepositories { repositories })
}
+ /// Construct this [`BackConfig`] from a config file.
+ ///
+ /// # Errors
+ ///
+ /// This function will return an error if the associated IO operations fail.
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(),
diff --git a/src/error/mod.rs b/src/error/mod.rs
index 026cc58..f109e11 100644
--- a/src/error/mod.rs
+++ b/src/error/mod.rs
@@ -10,126 +10,87 @@
// 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, net::SocketAddr, path::PathBuf};
+use std::{io, net::SocketAddr, path::PathBuf};
-use gix::hash::Prefix;
+use git_bug::{
+ entities::{identity::Identity, issue::Issue},
+ replica::{
+ self,
+ entity::{
+ id::prefix::{self, IdPrefix},
+ snapshot::Snapshot,
+ },
+ },
+};
use thiserror::Error;
-pub type Result<T> = std::result::Result<T, Error>;
+pub(crate) type Result<T> = std::result::Result<T, Error>;
#[derive(Error, Debug)]
pub enum Error {
+ #[error("while trying to parse the config file ({file}): {error}")]
ConfigParse {
file: PathBuf,
error: serde_json::Error,
},
- ProjectListRead {
- file: PathBuf,
- error: io::Error,
- },
- ConfigRead {
- file: PathBuf,
- error: io::Error,
- },
- NotGitBug {
- path: PathBuf,
- },
+ #[error("while trying to read the project.list file ({file}): {error}")]
+ ProjectListRead { file: PathBuf, error: io::Error },
+
+ #[error("while trying to read the config file ({file}): {error}")]
+ ConfigRead { file: PathBuf, error: io::Error },
+
+ #[error("Repository ('{path}') has no initialized git-bug data")]
+ NotGitBug { path: PathBuf },
+
+ #[error("while trying to open the repository ({repository_path}): {error}")]
RepoOpen {
repository_path: PathBuf,
- error: Box<gix::open::Error>,
- },
- RepoFind {
- repository_path: PathBuf,
+ error: Box<replica::open::Error>,
},
+
+ #[error("while trying to get an entity reference from a replica: {0}")]
+ RepoGetReferences(#[from] replica::get::Error),
+
+ #[error("while trying to read an issues's data from a replica: {0}")]
+ RepoIssueRead(#[from] replica::entity::read::Error<Issue>),
+
+ #[error("while trying to read an identity's data from a replica: {0}")]
+ RepoIdentityRead(#[from] replica::entity::read::Error<Identity>),
+
+ #[error("failed to find the repository at path: '{repository_path}'")]
+ RepoFind { repository_path: PathBuf },
+
+ #[error("while iteration over the refs in a repository: {0}")]
RepoRefsIter(#[from] gix::refs::packed::buffer::open::Error),
- RepoRefsPrefixed {
- error: io::Error,
- },
- TcpBind {
- addr: SocketAddr,
- err: io::Error,
- },
- TcpAccept {
- err: io::Error,
- },
+ #[error("while prefixing the refs with a path: {error}")]
+ RepoRefsPrefixed { error: io::Error },
+
+ #[error("while trying to open tcp {addr} for listening: {err}.")]
+ TcpBind { addr: SocketAddr, err: io::Error },
+ #[error("while trying to accept a tcp connection: {err}.")]
+ TcpAccept { err: io::Error },
+
+ #[error("There is no 'issue' associated with the prefix '{prefix}': {err}")]
IssuesPrefixMissing {
- prefix: Prefix,
+ prefix: Box<IdPrefix>,
+ err: prefix::resolve::Error,
},
- 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::ProjectListRead { file, error } => {
- write!(
- f,
- "while trying to read the project.list file ({}): {error}",
- file.display()
- )
- }
- Error::ConfigRead { file, error } => {
- write!(
- f,
- "while trying to read the config file ({}): {error}",
- file.display()
- )
- }
- Error::RepoOpen {
- repository_path,
- error,
- } => {
- write!(
- f,
- "while trying to open the repository ({}): {error}",
- repository_path.display()
- )
- }
- Error::NotGitBug { path } => {
- write!(
- f,
- "Repository ('{}') has no initialized git-bug data",
- path.display()
- )
- }
- Error::RepoFind { repository_path } => {
- write!(
- f,
- "failed to find the repository at path: '{}'",
- 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}")
- }
- Error::TcpBind { addr, err } => {
- write!(f, "while trying to open tcp {addr} for listening: {err}.")
- }
- Error::TcpAccept { err } => {
- write!(f, "while trying to accept a tcp connection: {err}.")
- }
- }
- }
+ #[error("The given prefix ('{prefix}') can not be parsed as prefix: {error}")]
+ IssuesPrefixParse {
+ prefix: String,
+ error: prefix::decode::Error,
+ },
+
+ #[error("Route '{0}' was unknown.")]
+ NotFound(PathBuf),
+
+ #[error("Query '{query}' was not valid: {err}")]
+ InvalidQuery {
+ err: git_bug::query::parse::parser::Error<Snapshot<Issue>>,
+ query: String,
+ },
}
diff --git a/src/main.rs b/src/main.rs
index 49ffe5c..28ca543 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -19,15 +19,12 @@ use crate::config::BackConfig;
mod cli;
pub mod config;
mod error;
-pub mod git_bug;
mod web;
-fn main() -> Result<(), String> {
+fn main() {
if let Err(err) = server_main() {
eprintln!("Error {err}");
process::exit(1);
- } else {
- Ok(())
}
}
@@ -37,9 +34,9 @@ async fn server_main() -> Result<(), error::Error> {
stderrlog::new()
.module(module_path!())
- .modules(["hyper", "http"])
+ .modules(["hyper", "http", "git_bug"])
.quiet(false)
- .show_module_names(false)
+ .show_module_names(true)
.color(stderrlog::ColorChoice::Auto)
.verbosity(2)
.timestamp(stderrlog::Timestamp::Off)
diff --git a/src/web/generate/mod.rs b/src/web/generate/mod.rs
index 06bab17..e68bddf 100644
--- a/src/web/generate/mod.rs
+++ b/src/web/generate/mod.rs
@@ -1,144 +1,353 @@
-// Back - An extremely simple git bug visualization system. Inspired by TVL's
-// panettone.
-//
-// Copyright (C) 2025 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};
-use gix::hash::Prefix;
-use log::info;
-use rinja::Template;
-use url::Url;
-
-use crate::{
- config::BackConfig,
- error,
- git_bug::{
- dag::issues_from_repository,
- issue::{CollapsedIssue, Status},
+use git_bug::{
+ entities::{
+ identity::Identity,
+ issue::{
+ Issue, data::status::Status, query::MatchKeyValue, snapshot::timeline::IssueTimeline,
+ },
+ },
+ query::{Matcher, Query},
+ replica::{
+ Replica,
+ entity::{
+ Entity,
+ id::prefix::IdPrefix,
+ snapshot::{
+ Snapshot,
+ timeline::{Timeline, history_step::HistoryStep},
+ },
+ },
},
};
+use log::info;
+use templates::to_markdown;
+use vy::{IntoHtml, a, div, footer, form, h1, header, input, li, main, nav, ol, p, span};
+
+use crate::{config::BackConfig, error, web::generate::templates::make_page};
-#[derive(Template)]
-#[template(path = "./issues.html")]
-struct IssuesTemplate {
- wanted_status: Status,
- counter_status: Status,
- issues: Vec<CollapsedIssue>,
+pub(crate) mod templates;
- /// The path to the repository
- repo_path: String,
+fn get_other_status(current: Status) -> Status {
+ match current {
+ Status::Open => Status::Closed,
+ Status::Closed => Status::Open,
+ }
+}
+
+fn set_query_status(
+ mut root_matcher: Matcher<Snapshot<Issue>>,
+ status: Status,
+) -> Matcher<Snapshot<Issue>> {
+ fn change_status(matcher: &mut Matcher<Snapshot<Issue>>, status: Status) {
+ match matcher {
+ Matcher::Or { lhs, rhs } | Matcher::And { lhs, rhs } => {
+ change_status(lhs, status);
+ change_status(rhs, status);
+ }
+ Matcher::Match { key_value } => {
+ if let MatchKeyValue::Status(found_status) = key_value {
+ *found_status = status;
+ }
+ }
+ }
+ }
- /// The URL to `back`'s source code
- source_code_repository_url: Url,
+ change_status(&mut root_matcher, status);
+ root_matcher
}
-pub fn issues(
+
+#[allow(clippy::too_many_lines)]
+pub(crate) fn issues(
config: &BackConfig,
- wanted_status: Status,
- counter_status: Status,
repo_path: &Path,
+ replica: &Replica,
+ query: &Query<Snapshot<Issue>>,
) -> error::Result<String> {
- let repository = config
- .repositories()?
- .get(repo_path)?
- .open(&config.scan_path)?;
-
- let mut issue_list = issues_from_repository(&repository.to_thread_local())?
+ let mut issue_list = replica
+ .get_all_with_query::<Issue>(query)?
+ .collect::<Result<Result<Vec<Issue>, _>, _>>()??
.into_iter()
- .map(|issue| issue.collapse())
- .filter(|issue| issue.status == wanted_status)
- .collect::<Vec<CollapsedIssue>>();
+ .map(|i| i.snapshot())
+ .map(|i| -> error::Result<_> {
+ let author = i.author();
+ Ok((i, author.resolve(replica)?.snapshot()))
+ })
+ .collect::<error::Result<Vec<_>>>()?;
// Sort by date descending.
// SAFETY:
// The time stamp is only used for sorting, so a malicious attacker could only affect the issue
// sorting.
- issue_list.sort_by_key(|issue| unsafe { issue.timestamp.to_unsafe() });
- issue_list.reverse();
+ issue_list.sort_by_key(|(issue, _)| unsafe {
+ issue
+ .timeline()
+ .history()
+ .first()
+ .expect("Is some")
+ .at()
+ .to_unsafe()
+ });
- Ok(IssuesTemplate {
- wanted_status,
- counter_status,
- source_code_repository_url: config.source_code_repository_url.clone(),
- issues: issue_list,
- repo_path: repo_path.display().to_string(),
- }
- .render()
- .expect("This should always work"))
-}
+ let root_matcher = query.as_matcher().unwrap_or(&Matcher::Match {
+ key_value: MatchKeyValue::Status(Status::Open),
+ });
+
+ let status = {
+ fn find_status(matcher: &Matcher<Snapshot<Issue>>) -> Option<Status> {
+ fn merge(status1: Option<Status>, status2: Option<Status>) -> Option<Status> {
+ match (status1, status2) {
+ (None, None) => None,
+ (None, Some(b)) => Some(b),
+ #[allow(clippy::match_same_arms)]
+ (Some(a), None) => Some(a),
+ // TODO(@bpeetz): Should we warn the user somehow? <2025-05-28>
+ (Some(a), Some(_)) => Some(a),
+ }
+ }
+
+ match matcher {
+ Matcher::Or { lhs, rhs } | Matcher::And { lhs, rhs } => {
+ merge(find_status(lhs), find_status(rhs))
+ }
+ Matcher::Match { key_value } => match key_value {
+ MatchKeyValue::Status(status) => Some(*status),
+ _ => None,
+ },
+ }
+ }
-use crate::git_bug::format::HtmlString;
-#[derive(Template)]
-#[template(path = "./issue.html")]
-struct IssueTemplate {
- issue: CollapsedIssue,
+ find_status(root_matcher)
+ };
- /// The path to the repository
- repo_path: String,
+ // TODO(@bpeetz): Be less verbose. <2025-06-06>
+ let query_string = query.to_string();
- /// The URL to `back`'s source code
- source_code_repository_url: Url,
+ Ok(make_page(
+ (
+ header!(h1!((
+ status.map_or(String::new(), |a| a.to_string()),
+ " Issues"
+ ))),
+ main!(
+ div!(
+ class = "issue-links",
+ if let Some(status) = status {
+ a!(
+ href = format!(
+ "/{}/issues/?query={}",
+ repo_path.display(),
+ Query::from_matcher(set_query_status(
+ root_matcher.clone(),
+ get_other_status(status)
+ ))
+ .to_string()
+ ),
+ "View ",
+ get_other_status(status).to_string(),
+ " issues"
+ )
+ },
+ a!(href = "/", "View repos",),
+ form!(
+ class = "issue-search",
+ method = "get",
+ input!(
+ name = "query",
+ value = &query_string,
+ title = "Issue search query",
+ "type" = "search",
+ placeholder = "status:open title:\"Test\"",
+ size = query_string.chars().count().clamp(20, 40),
+ ),
+ input!(
+ class = "sr-only",
+ "type" = "submit",
+ value = "Search Issues"
+ )
+ )
+ ),
+ ol!(
+ class = "issue-list",
+ issue_list.iter().map(|(issue, identity)| {
+ li!(a!(
+ href = format!("/{}/issue/{}", repo_path.display(), issue.id()),
+ p!(span!(
+ class = "issue-subject",
+ to_markdown(issue.title(), true)
+ )),
+ span!(
+ class = "issue-number",
+ issue.id().as_id().shorten().to_string()
+ ),
+ " ",
+ display_issue_open(identity, issue.timeline()),
+ if !issue.comments().is_empty() {
+ span!(
+ class = "comment-count",
+ " - ",
+ issue.comments().len(),
+ " ",
+ {
+ if issue.comments().len() > 1 {
+ "comments"
+ } else {
+ "comment"
+ }
+ }
+ )
+ }
+ ))
+ })
+ )
+ ),
+ footer!(nav!(a!(
+ href = config.source_code_repository_url.to_string(),
+ "Source Code"
+ ))),
+ ),
+ "issue list page",
+ None,
+ )
+ .into_string())
}
-pub fn issue(config: &BackConfig, repo_path: &Path, prefix: Prefix) -> error::Result<String> {
- let repository = config
+
+pub(crate) fn issue(
+ config: &BackConfig,
+ repo_path: &Path,
+ prefix: IdPrefix,
+) -> error::Result<String> {
+ let replica = config
.repositories()?
.get(repo_path)?
- .open(&config.scan_path)?
- .to_thread_local();
+ .open(&config.scan_path)?;
- let maybe_issue = issues_from_repository(&repository)?
- .into_iter()
- .map(|val| val.collapse())
- .find(|issue| issue.id.to_string().starts_with(&prefix.to_string()));
+ let issue = replica
+ .get(prefix.resolve::<Issue>(replica.repo()).map_err(|err| {
+ error::Error::IssuesPrefixMissing {
+ prefix: Box::new(prefix),
+ err,
+ }
+ })?)?
+ .snapshot();
- match maybe_issue {
- Some(issue) => Ok(IssueTemplate {
- issue,
- repo_path: repo_path.display().to_string(),
- source_code_repository_url: config.source_code_repository_url.clone(),
- }
- .render()
- .expect("This should always work")),
- None => Err(error::Error::IssuesPrefixMissing { prefix }),
- }
-}
+ let identity = issue.author().resolve(&replica)?.snapshot();
-#[derive(Template)]
-#[template(path = "./repos.html")]
-struct ReposTemplate {
- repos: Vec<RepoValue>,
+ Ok(make_page(
+ div!(
+ class = "content",
+ nav!(
+ a!(
+ href = format!("/{}/issues/?query=status:open", repo_path.display()),
+ "Open Issues"
+ ),
+ a!(
+ href = format!("/{}/issues/?query=status:closed", repo_path.display()),
+ "Closed Issues"
+ ),
+ ),
+ header!(
+ h1!(to_markdown(issue.title(), true)),
+ div!(
+ class = "issue-number",
+ issue.id().as_id().shorten().to_string()
+ )
+ ),
+ main!(
+ div!(
+ class = "issue-info",
+ display_issue_open(&identity, issue.timeline())
+ ),
+ to_markdown(issue.body(), false),
+ if !issue.comments().is_empty() {
+ ol!(
+ class = "issue-history",
+ issue
+ .comments()
+ .into_iter()
+ .map(|comment| {
+ Ok(li!(
+ class = "comment",
+ id = comment.combined_id.shorten().to_string(),
+ to_markdown(&comment.message, false),
+ p!(
+ class = "comment-info",
+ span!(
+ class = "user-name",
+ comment
+ .author
+ .resolve(&replica)?
+ .snapshot()
+ .name()
+ .to_owned(),
+ " at ",
+ comment.timestamp.to_string()
+ )
+ )
+ ))
+ })
+ .collect::<error::Result<Vec<_>>>()?
+ )
+ }
+ ),
+ footer!(nav!(a!(
+ href = config.source_code_repository_url.to_string(),
+ "Source code"
+ ),))
+ ),
+ "issue detail page",
+ Some(format!("{} | Back", issue.title()).as_str()),
+ )
+ .into_string())
+}
- /// The URL to `back`'s source code
- source_code_repository_url: Url,
+fn display_issue_open(
+ identity: &Snapshot<Identity>,
+ issue_timeline: &IssueTimeline,
+) -> impl IntoHtml {
+ span!(
+ class = "created-by-at",
+ "Opened by ",
+ span!(class = "user-name", identity.name()),
+ " ",
+ identity
+ .email()
+ .map(|email| span!(class = "user-email", "<", email, "> ",)),
+ "at ",
+ span!(class = "timestamp", {
+ {
+ issue_timeline
+ .history()
+ .last()
+ .expect("Exists")
+ .at()
+ .to_string()
+ }
+ })
+ )
}
+
struct RepoValue {
description: String,
owner: String,
path: String,
}
-pub fn repos(config: &BackConfig) -> error::Result<String> {
+
+pub(crate) fn repos(config: &BackConfig) -> error::Result<String> {
let repos: Vec<RepoValue> = config
.repositories()?
.iter()
.filter_map(|raw_repo| match raw_repo.open(&config.scan_path) {
- Ok(repo) => {
- let repo = repo.to_thread_local();
- let git_config = repo.config_snapshot();
+ Ok(replica) => {
+ let git_config = replica.repo().config_snapshot();
let path = raw_repo.path().to_string_lossy().to_string();
let owner = git_config
.string("cgit.owner")
- .map(|v| v.to_string())
- .unwrap_or("<No owner>".to_owned());
+ .map_or("<No owner>".to_owned(), |v| v.to_string());
- let description = fs::read_to_string(repo.git_dir().join("description"))
+ let description = fs::read_to_string(replica.repo().git_dir().join("description"))
.unwrap_or("<No description>".to_owned());
Some(RepoValue {
@@ -157,73 +366,124 @@ pub fn repos(config: &BackConfig) -> error::Result<String> {
})
.collect();
- Ok(ReposTemplate {
- repos,
- source_code_repository_url: config.source_code_repository_url.clone(),
- }
- .render()
- .expect("this should work"))
+ Ok(make_page(
+ div!(
+ class = "content",
+ header!(h1!("Repositiories")),
+ main!(
+ div!(
+ class = "issue-links",
+ // TODO(@bpeetz): Add a search. <2025-05-21>
+ ),
+ if !repos.is_empty() {
+ ol!(
+ class = "issue-list",
+ repos.into_iter().map(|repo| {
+ li!(a!(
+ href = format!("/{}/issues/?query=status:open", repo.path),
+ p!(span!(class = "issue-subject", repo.path)),
+ span!(
+ class = "created-by-at",
+ span!(class = "timestamp", repo.description),
+ " - ",
+ span!(class = "user-name", repo.owner)
+ )
+ ))
+ })
+ )
+ }
+ ),
+ footer!(nav!(a!(
+ href = config.source_code_repository_url.to_string(),
+ "Source Code"
+ ))),
+ ),
+ "repository listing page",
+ Some("Repos | Back"),
+ )
+ .into_string())
}
-pub fn feed(config: &BackConfig, repo_path: &Path) -> error::Result<String> {
+pub(crate) fn feed(config: &BackConfig, repo_path: &Path) -> error::Result<String> {
use rss::{ChannelBuilder, Item, ItemBuilder};
- let repository = config
+ let replica = config
.repositories()?
.get(repo_path)?
- .open(&config.scan_path)?
- .to_thread_local();
+ .open(&config.scan_path)?;
- let issues: Vec<CollapsedIssue> = issues_from_repository(&repository)?
+ let issues: Vec<Snapshot<Issue>> = replica
+ .get_all::<Issue>()?
+ .collect::<Result<Result<Vec<Issue>, _>, _>>()??
.into_iter()
- .map(|issue| issue.collapse())
- .collect();
+ .map(|i| i.snapshot())
+ .collect::<Vec<_>>();
// Collect all Items as rss items
let mut items: Vec<Item> = issues
.iter()
- .map(|issue| {
- ItemBuilder::default()
- .title(issue.title.to_string())
- .author(issue.author.to_string())
- .description(issue.message.to_string())
- .pub_date(issue.timestamp.to_string())
+ .map(|issue| -> error::Result<_> {
+ Ok(ItemBuilder::default()
+ .title(issue.title().to_string())
+ .author(
+ replica
+ .get(issue.author().id())?
+ .snapshot()
+ .name()
+ .to_owned(),
+ )
+ .description(issue.body().to_string())
+ .pub_date(
+ issue
+ .timeline()
+ .history()
+ .last()
+ .expect("Exists")
+ .at()
+ .to_string(),
+ )
.link(format!(
"/{}/{}/issue/{}",
repo_path.display(),
&config.root_url,
- issue.id
+ issue.id()
))
- .build()
+ .build())
})
- .collect();
+ .collect::<error::Result<Vec<Item>>>()?;
// Append all comments after converting them to rss items
items.extend(
issues
.iter()
- .filter(|issue| !issue.comments.is_empty())
+ .filter(|issue| !issue.comments().is_empty())
.flat_map(|issue| {
issue
- .comments
+ .comments()
.iter()
- .map(|comment| {
- ItemBuilder::default()
- .title(issue.title.to_string())
- .author(comment.author.to_string())
+ .map(|comment| -> error::Result<_> {
+ Ok(ItemBuilder::default()
+ .title(issue.title().to_string())
+ .author(
+ replica
+ .get(comment.author.id())?
+ .snapshot()
+ .name()
+ .to_owned(),
+ )
.description(comment.message.to_string())
.pub_date(comment.timestamp.to_string())
.link(format!(
"/{}/{}/issue/{}",
repo_path.display(),
&config.root_url,
- issue.id
+ comment.combined_id.shorten()
))
- .build()
+ .build())
})
- .collect::<Vec<Item>>()
+ .collect::<Vec<_>>()
})
- .collect::<Vec<Item>>(),
+ .collect::<error::Result<Vec<Item>>>()?,
);
let channel = ChannelBuilder::default()
diff --git a/src/web/generate/templates.rs b/src/web/generate/templates.rs
new file mode 100644
index 0000000..693b862
--- /dev/null
+++ b/src/web/generate/templates.rs
@@ -0,0 +1,44 @@
+use vy::{DOCTYPE, IntoHtml, PreEscaped, body, div, head, html, link, meta, title};
+
+pub(crate) fn make_page(
+ content: impl IntoHtml,
+ description: &str,
+ title: Option<&str>,
+) -> impl IntoHtml {
+ (
+ DOCTYPE,
+ html!(
+ lang = "en",
+ head!(
+ title!(title.unwrap_or("Back")),
+ link!(rel = "icon", href = "/favicon.ico"),
+ link!(rel = "stylesheet", "type" = "text/css", href = "/style.css"),
+ meta!(charset = "UTF-8"),
+ meta!(
+ name = "viewport",
+ content = "width=device-width,initial-scale=1"
+ ),
+ meta!(name = "description", content = description),
+ ),
+ body!(div!(class = "content", content))
+ ),
+ )
+}
+
+pub(super) fn to_markdown(input: &str, is_title: bool) -> PreEscaped<String> {
+ let markdown = markdown::to_html(input.trim());
+
+ // If the markdown contains only one line line, assuming that it is a title is okay.
+ if input.lines().count() == 1 && markdown.starts_with("<p>") && is_title {
+ PreEscaped(
+ markdown
+ .strip_prefix("<p>")
+ .expect("We checked")
+ .strip_suffix("</p>")
+ .expect("markdown crate produces no invalid html")
+ .to_owned(),
+ )
+ } else {
+ PreEscaped(markdown)
+ }
+}
diff --git a/src/web/mod.rs b/src/web/mod.rs
index 8e2e9b0..0f9835a 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -9,21 +9,28 @@
// 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, net::SocketAddr, path::PathBuf, sync::Arc};
+use std::{cell::OnceCell, convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc};
use bytes::Bytes;
+use git_bug::{
+ entities::issue::{Issue, data::status::Status, query::MatchKeyValue},
+ query::{Matcher, ParseMode, Query},
+ replica::entity::{id::prefix::IdPrefix, snapshot::Snapshot},
+};
use http_body_util::combinators::BoxBody;
use hyper::{Method, Request, Response, StatusCode, server::conn::http1, service::service_fn};
use hyper_util::rt::TokioIo;
-use log::{error, info};
-use responses::{html_response, html_response_status, html_response_status_content_type};
+use log::{error, info, warn};
+use responses::{html_response, html_response_status, html_response_status_content_type, redirect};
use tokio::net::TcpListener;
+use url::Url;
-use crate::{config::BackConfig, error, git_bug::issue::Status};
+use crate::{config::BackConfig, error};
mod generate;
mod responses;
+#[allow(clippy::unused_async, clippy::too_many_lines)]
async fn match_uri(
config: Arc<BackConfig>,
req: Request<hyper::body::Incoming>,
@@ -36,36 +43,69 @@ async fn match_uri(
}
let output = || -> Result<Response<BoxBody<Bytes, Infallible>>, error::Error> {
- match req.uri().path().trim_end_matches("/") {
+ match req.uri().path().trim_matches('/') {
"" => Ok(html_response(generate::repos(&config)?)),
- "/style.css" => Ok(responses::html_response_status_content_type(
+ "style.css" => Ok(html_response_status_content_type(
include_str!("../../assets/style.css"),
StatusCode::OK,
"text/css",
)),
+ "search.png" => Ok(html_response_status_content_type(
+ &include_bytes!("../../assets/search.png")[..],
+ StatusCode::OK,
+ "image/png",
+ )),
- path if path.ends_with("/issues/open") => {
- let repo_path = PathBuf::from(
- path.strip_suffix("/issues/open")
- .expect("This suffix exists")
- .strip_prefix("/")
- .expect("This also exists"),
- );
+ path if path.ends_with("/issues") => {
+ let repo_path =
+ PathBuf::from(path.strip_suffix("/issues").expect("This suffix exists"));
- let issues = generate::issues(&config, Status::Open, Status::Closed, &repo_path)?;
- Ok(html_response(issues))
- }
- path if path.ends_with("/issues/closed") => {
- let repo_path = PathBuf::from(
- path.strip_suffix("/issues/closed")
- .expect("This suffix exists")
- .strip_prefix("/")
- .expect("This also exists"),
- );
+ let replica = config
+ .repositories()?
+ .get(&repo_path)?
+ .open(&config.scan_path)?;
+
+ let query = {
+ // HACK(@bpeetz): We cannot use the uri directly, because that would require us
+ // to parse the query string. As such, we need to turn into into a url::Url,
+ // which provides this. Unfortunately, this re-parsing is the “officially”
+ // sanctioned way of doing this. <2025-05-28>
+ let url: Url = format!("https://{}{}", config.root_url, req.uri())
+ .parse()
+ .expect("Was a url before");
+
+ let mut query = OnceCell::new();
+ for (name, value) in url.query_pairs() {
+ if name == "query" && query.get().is_none() {
+ let val =
+ Query::from_continuous_str(&replica, &value, ParseMode::Relaxed)
+ .map_err(|err| error::Error::InvalidQuery {
+ err,
+ query: value.to_string(),
+ })?;
+ query.set(val).expect("We checked for initialized");
+ } else {
+ warn!("Unknown url query key: {name}");
+ }
+ }
+
+ query.take()
+ };
- let issues = generate::issues(&config, Status::Closed, Status::Open, &repo_path)?;
- Ok(html_response(issues))
+ if let Some(query) = query {
+ let issues = generate::issues(&config, &repo_path, &replica, &query)?;
+ Ok(html_response(issues))
+ } else {
+ let query = Query::<Snapshot<Issue>>::from_matcher(Matcher::Match {
+ key_value: MatchKeyValue::Status(Status::Open),
+ });
+ Ok(redirect(&format!(
+ "/{}/issues/?query={}",
+ repo_path.display(),
+ query
+ )))
+ }
}
path if path.ends_with("/issues/feed") => {
let repo_path = PathBuf::from(
@@ -87,22 +127,30 @@ async fn match_uri(
let (repo_path, prefix) = {
let split: Vec<&str> = path.split("/issue/").collect();
- let prefix =
- gix::hash::Prefix::from_hex(split[1]).map_err(error::Error::from)?;
+ let prefix = IdPrefix::from_hex_bytes(split[1].as_bytes()).map_err(|err| {
+ error::Error::IssuesPrefixParse {
+ prefix: split[1].to_owned(),
+ error: err,
+ }
+ })?;
- let repo_path =
- PathBuf::from(split[0].strip_prefix("/").expect("This prefix exists"));
+ let repo_path = PathBuf::from(split[0]);
(repo_path, prefix)
};
Ok(html_response(generate::issue(&config, &repo_path, prefix)?))
}
- other => Ok(responses::html_response_status_content_type(
- format!("'{}' not found", other),
- StatusCode::NOT_FOUND,
- "text/plain",
- )),
+ other if config.repositories()?.get(&PathBuf::from(other)).is_ok() => {
+ Ok(redirect(&format!("/{other}/issues/?query=status:open")))
+ }
+ other if config.repositories()?.get(&PathBuf::from(other)).is_err() => {
+ Err(error::Error::NotGitBug {
+ path: PathBuf::from(other),
+ })
+ }
+
+ other => Err(error::Error::NotFound(PathBuf::from(other))),
}
};
match output() {
@@ -111,13 +159,14 @@ async fn match_uri(
}
}
-pub async fn main(config: Arc<BackConfig>) -> Result<(), error::Error> {
+pub(crate) async fn main(config: Arc<BackConfig>) -> Result<(), error::Error> {
let addr: SocketAddr = ([127, 0, 0, 1], 8000).into();
let listener = TcpListener::bind(addr)
.await
.map_err(|err| error::Error::TcpBind { addr, err })?;
- info!("Listening on http://{}", addr);
+ info!("Listening on http://{addr}");
+
loop {
let (stream, _) = listener
.accept()
@@ -131,7 +180,7 @@ pub async fn main(config: Arc<BackConfig>) -> Result<(), error::Error> {
tokio::task::spawn(async move {
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
- error!("Error serving connection: {:?}", err);
+ error!("Error serving connection: {err:?}");
}
});
}
diff --git a/src/web/responses.rs b/src/web/responses.rs
index bcdcc0a..00af35a 100644
--- a/src/web/responses.rs
+++ b/src/web/responses.rs
@@ -12,10 +12,13 @@
use std::convert::Infallible;
use bytes::Bytes;
+use git_bug::replica::entity::id::prefix::IdPrefix;
use http::{Response, StatusCode, Version};
use http_body_util::{BodyExt, Full, combinators::BoxBody};
+use vy::{IntoHtml, h1, p, pre};
-use crate::{error, git_bug::format::HtmlString};
+use super::generate::templates::make_page;
+use crate::error;
pub(super) fn html_response<T: Into<Bytes>>(html_text: T) -> Response<BoxBody<Bytes, Infallible>> {
html_response_status(html_text, StatusCode::OK)
@@ -28,6 +31,15 @@ pub(super) fn html_response_status<T: Into<Bytes>>(
html_response_status_content_type(html_text, status, "text/html")
}
+pub(super) fn redirect(target: &str) -> Response<BoxBody<Bytes, Infallible>> {
+ Response::builder()
+ .status(StatusCode::MOVED_PERMANENTLY)
+ .version(Version::HTTP_2)
+ .header("Location", target)
+ .body(full(""))
+ .expect("This is hardcoded and will build")
+}
+
pub(super) fn html_response_status_content_type<T: Into<Bytes>>(
html_text: T,
status: StatusCode,
@@ -36,26 +48,129 @@ pub(super) fn html_response_status_content_type<T: Into<Bytes>>(
Response::builder()
.status(status)
.version(Version::HTTP_2)
- .header("Content-Type", format!("{}; charset=utf-8", content_type))
+ .header("Content-Type", format!("{content_type}; charset=utf-8"))
.header("x-content-type-options", "nosniff")
.header("x-frame-options", "SAMEORIGIN")
.body(full(html_text))
.expect("This will always build")
}
-fn full<T: Into<Bytes>>(chunk: T) -> BoxBody<Bytes, Infallible> {
+pub(super) fn full<T: Into<Bytes>>(chunk: T) -> BoxBody<Bytes, Infallible> {
Full::new(chunk.into()).boxed()
}
-// FIXME: Not all errors should return `INTERNAL_SERVER_ERROR`. <2025-03-08>
impl error::Error {
+ #[allow(clippy::too_many_lines)]
pub fn into_response(self) -> Response<BoxBody<Bytes, Infallible>> {
- html_response_status(
- format!(
- "<h1> Internal server error. </h1> <pre>Error: {}</pre>",
- HtmlString::from(self.to_string())
+ match self {
+ error::Error::ConfigParse { .. }
+ | error::Error::ProjectListRead { .. }
+ | error::Error::ConfigRead { .. }
+ | error::Error::RepoGetReferences(_)
+ | error::Error::RepoIssueRead(_)
+ | error::Error::RepoIdentityRead(_)
+ | error::Error::RepoFind { .. }
+ | error::Error::RepoRefsIter(_)
+ | error::Error::RepoRefsPrefixed { .. }
+ | error::Error::TcpBind { .. }
+ | error::Error::RepoOpen { .. }
+ | error::Error::TcpAccept { .. } => html_response_status(
+ make_page(
+ (
+ h1!("Internal server error"),
+ pre!(format!("Error {}", self.to_string())),
+ ),
+ "Error page",
+ Some("Error | Back"),
+ )
+ .into_string(),
+ StatusCode::INTERNAL_SERVER_ERROR,
+ ),
+ error::Error::NotGitBug { path } => html_response_status(
+ make_page(
+ (
+ h1!("Expectation Failed"),
+ p!(
+ "Repository '",
+ path.display().to_string(),
+ "' has no git bug data to show."
+ ),
+ ),
+ "Error page",
+ Some("Error | Back"),
+ )
+ .into_string(),
+ StatusCode::EXPECTATION_FAILED,
+ ),
+ error::Error::IssuesPrefixMissing { prefix, err } => html_response_status(
+ make_page(
+ (
+ h1!("Expectation Failed"),
+ p!(
+ "There is no issue associated with the prefix ",
+ "'",
+ prefix.to_string(),
+ "': ",
+ err.to_string()
+ ),
+ ),
+ "Error page",
+ Some("Error | Back"),
+ )
+ .into_string(),
+ StatusCode::EXPECTATION_FAILED,
+ ),
+ error::Error::IssuesPrefixParse { prefix, error } => html_response_status(
+ make_page(
+ (
+ h1!("Expectation Failed"),
+ p!(
+ "The prefix '",
+ prefix,
+ "' cannot be interperted as a prefix",
+ ),
+ p!(error.to_string()),
+ p!(
+ "The prefix is composed of ",
+ IdPrefix::REQUIRED_LENGTH,
+ " or more hex chars."
+ ),
+ ),
+ "Error page",
+ Some("Error | Back"),
+ )
+ .into_string(),
+ StatusCode::EXPECTATION_FAILED,
+ ),
+ error::Error::NotFound(path_buf) => html_response_status(
+ make_page(
+ (
+ h1!("Expectation Failed"),
+ p!(
+ "The path '",
+ path_buf.display().to_string(),
+ "' was unkonwn",
+ ),
+ ),
+ "Error page",
+ Some("Error | Back"),
+ )
+ .into_string(),
+ StatusCode::EXPECTATION_FAILED,
+ ),
+ error::Error::InvalidQuery { err, query } => html_response_status(
+ make_page(
+ (
+ h1!("Expectation Failed"),
+ p!("The query '", query, "' was invalid",),
+ p!(err.to_string()),
+ ),
+ "Error page",
+ Some("Error | Back"),
+ )
+ .into_string(),
+ StatusCode::EXPECTATION_FAILED,
),
- StatusCode::INTERNAL_SERVER_ERROR,
- )
+ }
}
}