about summary refs log tree commit diff stats
path: root/src/web
diff options
context:
space:
mode:
Diffstat (limited to 'src/web')
-rw-r--r--src/web/generate/mod.rs522
-rw-r--r--src/web/generate/templates.rs44
-rw-r--r--src/web/mod.rs125
-rw-r--r--src/web/responses.rs135
4 files changed, 647 insertions, 179 deletions
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};
 
-#[derive(Template)]
-#[template(path = "./issues.html")]
-struct IssuesTemplate {
-    wanted_status: Status,
-    counter_status: Status,
-    issues: Vec<CollapsedIssue>,
+use crate::{config::BackConfig, error, web::generate::templates::make_page};
 
-    /// The path to the repository
-    repo_path: String,
+pub(crate) mod templates;
 
-    /// The URL to `back`'s source code
-    source_code_repository_url: Url,
+fn get_other_status(current: Status) -> Status {
+    match current {
+        Status::Open => Status::Closed,
+        Status::Closed => Status::Open,
+    }
 }
-pub fn issues(
+
+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;
+                }
+            }
+        }
+    }
+
+    change_status(&mut root_matcher, status);
+    root_matcher
+}
+
+#[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();
-
-    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"))
-}
+    issue_list.sort_by_key(|(issue, _)| unsafe {
+        issue
+            .timeline()
+            .history()
+            .first()
+            .expect("Is some")
+            .at()
+            .to_unsafe()
+    });
+
+    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),
+                }
+            }
 
-use crate::git_bug::format::HtmlString;
-#[derive(Template)]
-#[template(path = "./issue.html")]
-struct IssueTemplate {
-    issue: CollapsedIssue,
+            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,
+                },
+            }
+        }
 
-    /// The path to the repository
-    repo_path: String,
+        find_status(root_matcher)
+    };
 
-    /// The URL to `back`'s source code
-    source_code_repository_url: Url,
+    // TODO(@bpeetz): Be less verbose. <2025-06-06>
+    let query_string = query.to_string();
+
+    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()));
-
-    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 issue = replica
+        .get(prefix.resolve::<Issue>(replica.repo()).map_err(|err| {
+            error::Error::IssuesPrefixMissing {
+                prefix: Box::new(prefix),
+                err,
+            }
+        })?)?
+        .snapshot();
+
+    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"),
-                );
-
-                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"),
-                );
+            path if path.ends_with("/issues") => {
+                let repo_path =
+                    PathBuf::from(path.strip_suffix("/issues").expect("This suffix 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,
-        )
+        }
     }
 }