// Back - An extremely simple git bug visualization system. Inspired by TVL's // panettone. // // Copyright (C) 2025 Benedikt Peetz // SPDX-License-Identifier: AGPL-3.0-or-later // // This file is part of Back. // // You should have received a copy of the License along with this program. // If not, see . use std::{fs, path::Path}; 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}; pub(crate) mod templates; 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>, status: Status, ) -> Matcher> { fn change_status(matcher: &mut Matcher>, 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, repo_path: &Path, replica: &Replica, query: &Query>, ) -> error::Result { let mut issue_list = replica .get_all_with_query::(query)? .collect::, _>, _>>()?? .into_iter() .map(|i| i.snapshot()) .map(|i| -> error::Result<_> { let author = i.author(); Ok((i, author.resolve(replica)?.snapshot())) }) .collect::>>()?; // 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 .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>) -> Option { fn merge(status1: Option, status2: Option) -> Option { 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, }, } } find_status(root_matcher) }; // 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(crate) fn issue( config: &BackConfig, repo_path: &Path, prefix: IdPrefix, ) -> error::Result { let replica = config .repositories()? .get(repo_path)? .open(&config.scan_path)?; let issue = replica .get(prefix.resolve::(replica.repo()).map_err(|err| { error::Error::IssuesPrefixMissing { prefix: Box::new(prefix), err, } })?)? .snapshot(); let identity = issue.author().resolve(&replica)?.snapshot(); 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::>>()? ) } ), 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()) } fn display_issue_open( identity: &Snapshot, 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(crate) fn repos(config: &BackConfig) -> error::Result { let repos: Vec = config .repositories()? .iter() .filter_map(|raw_repo| match raw_repo.open(&config.scan_path) { 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_or("".to_owned(), |v| v.to_string()); let description = fs::read_to_string(replica.repo().git_dir().join("description")) .unwrap_or("".to_owned()); Some(RepoValue { description, owner, path, }) } Err(err) => { info!( "Repo '{}' could not be opened: '{err}'", raw_repo.path().display() ); None } }) .collect(); 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(crate) fn feed(config: &BackConfig, repo_path: &Path) -> error::Result { use rss::{ChannelBuilder, Item, ItemBuilder}; let replica = config .repositories()? .get(repo_path)? .open(&config.scan_path)?; let issues: Vec> = replica .get_all::()? .collect::, _>, _>>()?? .into_iter() .map(|i| i.snapshot()) .collect::>(); // Collect all Items as rss items let mut items: Vec = issues .iter() .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() )) .build()) }) .collect::>>()?; // Append all comments after converting them to rss items items.extend( issues .iter() .filter(|issue| !issue.comments().is_empty()) .flat_map(|issue| { issue .comments() .iter() .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, comment.combined_id.shorten() )) .build()) }) .collect::>() }) .collect::>>()?, ); let channel = ChannelBuilder::default() .title("Issues") .link(config.root_url.to_string()) .description(format!("The rss feed for issues on {}.", &config.root_url)) .items(items) .build(); Ok(channel.to_string()) }