crates/gunnar-public-site/src/issues.rs
//! **A public repository's issues** (PUB-ISSUES, owner 2026-10-29): the public
//! bug tracker with a searchable archive that the OpenSSF badge asks for.
//!
//! Read-only: [`read`] asks the [`Source`] on the refresh timer (one issue list per
//! public repository, and a thread only where its list row moved), and the archive
//! (`issues.md`) and the sitemap are built from what was read. The list, one issue and
//! the search ([`Filter`]) are the console's own pages since GUN-GUEST (owner
//! 2026-10-30); the console pre-renders them for a crawler and filters with [`Filter`].
//!
//! Which issues are public is not decided here. gunnar answers the grantless
//! guest's `Forge.ListIssues` only for a repository it calls public (Read on a
//! public repository is everybody's), the site reads issues only for the
//! repositories it already publishes, and the whole of it is behind the site's
//! switch ([`crate::Switch`]). Filing and commenting stay the console's, behind a
//! sign-in: every page says so, with the way there.
use std::collections::HashMap;
use std::fmt::Write as _;
use std::sync::Arc;
use crate::head::{repo_base, ymd};
use crate::{text, Config, Issue, IssueNote, Limits, RepoRef, RepoSnapshot, Source};
/// The most `issues.md` grows to.
const ARCHIVE_BYTES: usize = 1024 * 1024;
/// The most of a search a request carries: longer words are cut here.
const MAX_QUERY_BYTES: usize = 200;
/// `/{ns}/{name}/issues`, the list page's path, and the key the issues are kept under.
pub fn list_path(repo: &RepoRef) -> String {
format!("{}/issues", repo_base(repo))
}
/// `/{ns}/{name}/issues/{n}`.
pub fn issue_path(repo: &RepoRef, number: u64) -> String {
format!("{}/issues/{number}", repo_base(repo))
}
/// `/{ns}/{name}/issues.md`: the whole archive as Markdown.
pub fn archive_path(repo: &RepoRef) -> String {
format!("{}/issues.md", repo_base(repo))
}
/// What one refresh keeps of each public repository's issues, by [`list_path`].
pub type Kept = HashMap<String, (RepoRef, Arc<Vec<Issue>>)>;
/// **The refresh's issue reads.** For each repository already published, ONE
/// `issues` call; a thread is read again only where the list row moved (its
/// state or its comment count) and at most [`Limits::max_issue_reads`] times per
/// refresh. A repository whose list cannot be read keeps what it had.
pub async fn read(source: &dyn Source, limits: &Limits, repos: &[(RepoRef, Arc<RepoSnapshot>)], before: &Kept) -> Kept {
let mut out = Kept::new();
let mut budget = limits.max_issue_reads;
for (repo, _) in repos {
let key = list_path(repo);
let previous = before.get(&key).map(|(_, v)| v.clone());
let Ok(mut list) = source.issues(repo).await else {
if let Some(p) = previous {
out.insert(key, (repo.clone(), p));
}
continue;
};
list.sort_by_key(|i| i.number);
list.dedup_by_key(|i| i.number);
if list.len() > limits.max_issues {
list = list.split_off(list.len() - limits.max_issues);
}
for issue in &mut list {
issue.title = text::cut(&text::one_line(&issue.title), 512).to_owned();
issue.body = text::cut(&issue.body, limits.max_issue_text_bytes).to_owned();
let old = previous.as_ref().and_then(|p| p.iter().find(|o| o.number == issue.number));
let unmoved = old.filter(|o| o.open == issue.open && o.comments == issue.comments && o.thread.is_some());
issue.thread = if let Some(o) = unmoved {
o.thread.clone()
} else if issue.open && issue.comments == 0 {
// An open issue nobody answered has no thread to read.
Some(Vec::new())
} else if budget > 0 {
budget -= 1;
match source.issue_thread(repo, issue.number).await {
Ok(notes) => Some(notes.into_iter().map(|n| IssueNote { body: text::cut(&n.body, limits.max_issue_text_bytes).to_owned(), ..n }).collect()),
Err(_) => None,
}
} else {
// Read on a later refresh; the page says so meanwhile.
None
};
}
out.insert(key, (repo.clone(), Arc::new(list)));
}
out
}
/// Which issues a list shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
All,
Open,
Closed,
}
/// **A search**, from a request's query: `q` (every word must appear, in any
/// case, in the title, the body or the thread; `#N` is issue N) and `state`
/// (`open`, `closed`, or all).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Filter {
pub q: String,
pub state: State,
}
impl Filter {
/// The filter a query asks for; `None` when it asks for nothing (the plain list).
pub fn of_query(query: Option<&str>) -> Option<Filter> {
let mut q = String::new();
let mut state = State::All;
for pair in query.unwrap_or_default().split('&') {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
let v = text::pct_decode(&v.replace('+', " ")).unwrap_or_default();
match k {
"q" => q = text::cut(v.trim(), MAX_QUERY_BYTES).to_owned(),
"state" => {
state = match v.as_str() {
"open" => State::Open,
"closed" => State::Closed,
_ => State::All,
}
}
_ => {}
}
}
(!q.is_empty() || state != State::All).then_some(Filter { q, state })
}
/// Does `issue` answer this search?
pub fn matches(&self, issue: &Issue) -> bool {
let state = match self.state {
State::All => true,
State::Open => issue.open,
State::Closed => !issue.open,
};
if !state {
return false;
}
let hay = || {
let mut h = format!("{}\n{}", issue.title, issue.body);
for n in issue.thread.iter().flatten() {
h.push('\n');
h.push_str(&n.body);
}
h.to_lowercase()
};
let hay = hay();
self.q.split_whitespace().all(|word| match word.strip_prefix('#').and_then(|n| n.parse::<u64>().ok()) {
Some(n) => issue.number == n,
None => hay.contains(&word.to_lowercase()),
})
}
}
/// **`/{ns}/{name}/issues.md`**: every issue and its thread in one Markdown
/// file, oldest first (bounded), for AI tools and for anybody who wants the
/// archive whole.
pub fn archive_md(config: &Config, repo: &RepoRef, issues: &[Issue]) -> String {
let open = issues.iter().filter(|i| i.open).count();
let mut s = format!(
"# {}: issues\n\n{open} open, {} closed. Read them at {}{}; to file one or comment, log in at {}/login.\n\n",
repo.path(),
issues.len() - open,
config.base_url,
list_path(repo),
config.base_url
);
for i in issues {
if s.len() >= ARCHIVE_BYTES {
let _ = writeln!(s, "(The rest is left out here; see {}{}.)", config.base_url, list_path(repo));
break;
}
let _ = writeln!(s, "## #{} {} ({}, opened {})\n\n{}{}\n", i.number, i.title, if i.open { "open" } else { "closed" }, ymd(i.opened_at_unix), config.base_url, issue_path(repo, i.number));
if !i.body.trim().is_empty() {
let _ = writeln!(s, "{}\n", i.body.trim_end());
}
for n in i.thread.iter().flatten() {
let _ = writeln!(s, "### {}, {}\n\n{}\n", if n.closed { "Closed" } else { "Comment" }, ymd(n.at_unix), n.body.trim_end());
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn issue(number: u64, open: bool, title: &str) -> Issue {
Issue { number, title: title.into(), open, ..Issue::default() }
}
#[test]
fn a_query_is_a_filter_and_an_empty_one_is_the_list() {
assert_eq!(Filter::of_query(None), None);
assert_eq!(Filter::of_query(Some("")), None);
assert_eq!(Filter::of_query(Some("q=&state=all")), None);
assert_eq!(Filter::of_query(Some("q=two+words&state=closed")), Some(Filter { q: "two words".into(), state: State::Closed }));
assert_eq!(Filter::of_query(Some("q=%C3%A5ngest")).map(|f| f.q), Some("ångest".to_owned()));
let long = format!("q={}", "a".repeat(1000));
assert_eq!(Filter::of_query(Some(&long)).map(|f| f.q.len()), Some(MAX_QUERY_BYTES));
}
#[test]
fn every_word_must_match_and_a_number_is_an_issue() {
let mut i = issue(7, true, "The Boot is dark");
i.thread = Some(vec![IssueNote { at_unix: 0, body: "on mondays".into(), closed: false }]);
let f = |q: &str| Filter { q: q.into(), state: State::All };
assert!(f("boot MONDAYS").matches(&i));
assert!(!f("boot tuesdays").matches(&i));
assert!(f("#7").matches(&i) && !f("#8").matches(&i));
assert!(!Filter { q: String::new(), state: State::Closed }.matches(&i));
}
}