Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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));
    }
}