git-ents.gitmain
⌘K
foforge
commit c7852a3
feat: make the Issues tab functional via refs/meta/issues docs

Add a typed Issue document on its own refs/meta/issues/<id> ref, read through git-store, and render the real open/closed list with label filter chips derived from the labels that exist. gather_meta now reports the live open-issue count, retiring the hardcoded zero. Issue creation is a write path and stays out of scope. Pinned with a fixed-format load test.

feat: add git_ents::issues with Issue/load/store/list/open_count feat: render the real issue list in issues_page feat: wire the live open-issue count into RepoMeta feat: render Issue and Config through the Render trait test: pin the on-disk issue format against a fixed fixture Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/git-ents/src/lib.rs @@ -2,6 +2,7 @@ pub mod checks; pub mod config; +pub mod issues; pub mod signers; #[cfg(test)] mod testutil;
crates/git-ents/src/testutil.rs @@ -99,6 +99,50 @@ assert!(status.success()); } +/// Lay an `Issue` document out at `refname` as the real on-disk format: +/// `title`, `body`, `state`, and `author` blobs plus an index-keyed (`0000`, +/// `0001`, …) `labels/` subtree, committed and pointed to by the ref. Asserts +/// the loader still reads the format independent of the writer. +pub(crate) fn write_issue_doc( + repo: &Path, + refname: &str, + title: &str, + body: &str, + state: &str, + labels: &[&str], + author: &str, +) { + let blob = |value: &str| git_with_stdin(repo, &["hash-object", "-w", "--stdin"], value); + let title_blob = blob(title); + let body_blob = blob(body); + let state_blob = blob(state); + let author_blob = blob(author); + let mut label_entries = String::new(); + for (index, label) in labels.iter().enumerate() { + label_entries.push_str(&format!("100644 blob {}\t{index:04}\n", blob(label))); + } + let labels_tree = git_with_stdin(repo, &["mktree"], &label_entries); + let root = git_with_stdin( + repo, + &["mktree"], + &format!( + "100644 blob {title_blob}\ttitle\n\ + 100644 blob {body_blob}\tbody\n\ + 100644 blob {state_blob}\tstate\n\ + 040000 tree {labels_tree}\tlabels\n\ + 100644 blob {author_blob}\tauthor\n" + ), + ); + let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], ""); + let status = Command::new("git") + .arg("-C") + .arg(repo) + .args(["update-ref", refname, &commit]) + .status() + .unwrap(); + assert!(status.success()); +} + /// Run git in `repo` with `input` on stdin, returning its trimmed stdout. fn git_with_stdin(repo: &Path, args: &[&str], input: &str) -> String { let mut child = Command::new("git")
crates/git-ents-server/src/web/mod.rs @@ -74,7 +74,7 @@ Some((&"commit", &[sha])) => pages::commit_page(repo, &meta, sha).await, Some((&"releases", &[])) => pages::releases_page(repo, &meta).await.into_response(), Some((&"checks", &[])) => pages::checks_page(repo, &meta).await.into_response(), - Some((&"issues", &[])) => pages::issues_page(&meta).into_response(), + Some((&"issues", &[])) => pages::issues_page(repo, &meta).await.into_response(), Some((&"settings", &[])) => pages::settings_page(repo, &meta).await.into_response(), _ => not_found().into_response(), } @@ -129,6 +129,7 @@ .await .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count()) .unwrap_or(0); + let issues = open_issue_count(repo).await; RepoMeta { rel: rel.to_owned(), branch, @@ -136,7 +137,7 @@ homepage, topics, releases, - issues: 0, + issues, } } @@ -149,6 +150,16 @@ .ok() } +/// Count the repository's open issues off the async runtime. +async fn open_issue_count(repo: &Path) -> usize { + let repo = repo.to_owned(); + tokio::task::spawn_blocking(move || git_ents::issues::open_count(&repo)) + .await + .ok() + .and_then(Result::ok) + .unwrap_or(0) +} + /// Wrap a repository view in the shared header band and tab bar, then the page /// shell. `active` highlights the current tab. fn repo_shell(meta: &RepoMeta, active: Tab, title: &str, body: Markup) -> Markup {
crates/git-ents-server/src/web/pages.rs @@ -742,9 +742,54 @@ .map_err(|err| err.to_string()) } -/// The Issues ("Bug reports") tab. There is no issue store yet, so the filters -/// are present for the design and the list is an empty state. -pub(super) fn issues_page(meta: &RepoMeta) -> Markup { +/// The Issues ("Bug reports") tab: the real issue list from +/// `refs/meta/issues/<id>`, split into open and closed, with the filter chips +/// derived from the labels that exist. Issue creation is a write path that does +/// not exist yet, so the "New issue" button stays disabled. +pub(super) async fn issues_page(repo: &Path, meta: &RepoMeta) -> Markup { + let issues = load_issues(repo).await; + let body = match &issues { + Err(err) => html! { div.card { div.card-row.muted { "Could not read issues: " (err) } } }, + Ok(issues) => { + let open: Vec<&(String, git_ents::issues::Issue)> = + issues.iter().filter(|(_id, i)| i.is_open()).collect(); + let closed = issues.len().saturating_sub(open.len()); + let mut labels: Vec<&str> = issues + .iter() + .flat_map(|(_id, i)| i.labels.iter().map(String::as_str)) + .collect(); + labels.sort_unstable(); + labels.dedup(); + html! { + div.filter-row { + div.filter-search { + (icon_search()) + input type="search" placeholder="Filter bug reports" aria-label="Filter" disabled; + } + span.chip.active { "All" } + @for label in &labels { + span.chip { (label) } + } + } + div.card { + div.card-header.subtabs { + span.subtab.active { (icon_issue()) "Open" span.tab-count { (open.len()) } } + span.subtab { (icon_check()) "Closed" span.tab-count { (closed) } } + } + @if open.is_empty() { + div.blankslate { + h2 { "No open bug reports" } + p { "Open one to start tracking a bug." } + } + } @else { + @for (_id, issue) in &open { + (issue.render()) + } + } + } + } + } + }; repo_shell( meta, Tab::Issues, @@ -754,30 +799,21 @@ h1.page-title { "Bug reports" } button.btn-primary type="button" disabled title="Not available yet" { (icon_plus()) "New issue" } } - div.filter-row.stub title="Not available yet" { - div.filter-search { - (icon_search()) - input type="search" placeholder="Filter bug reports" aria-label="Filter" disabled; - } - span.chip.active { "All" } - span.chip { "bug" } - span.chip { "enhancement" } - span.chip { "question" } - } - div.card { - div.card-header.subtabs.stub title="Not available yet" { - span.subtab.active { (icon_issue()) "Open" span.tab-count { "0" } } - span.subtab { (icon_check()) "Closed" span.tab-count { "0" } } - } - div.blankslate { - h2 { "No bug reports yet" } - p { "Open one to start tracking a bug." } - } - } + (body) }, ) } +/// Load the repository's issues off the async runtime, since `issues::list` +/// reads the object database synchronously. +async fn load_issues(repo: &Path) -> Result<Vec<(String, git_ents::issues::Issue)>, String> { + let repo = repo.to_owned(); + tokio::task::spawn_blocking(move || git_ents::issues::list(&repo)) + .await + .map_err(|err| err.to_string())? + .map_err(|err| err.to_string()) +} + /// The Settings tab. Persisting changes needs a config store that does not /// exist yet, so the controls reflect the repository's current real values and /// are presented read-only.
crates/git-ents-server/src/web/render.rs @@ -12,6 +12,8 @@ use maud::{Markup, html}; use git_ents::checks::{Check, Run}; +use git_ents::config::Config; +use git_ents::issues::Issue; use git_ents::signers::Signer; /// HTML rendering for a meta-ref value. The default walks the value's [`Facet`] @@ -27,6 +29,24 @@ /// A check renders structurally: its name is the key, its command the value. impl Render for Check {} +/// Config renders structurally: each field becomes a keyed row. +impl Render for Config {} + +/// An issue's title leads the row, its labels render as chips beside it rather +/// than the raw ` · `-joined list the structural walk would print. +impl Render for Issue { + fn render(&self) -> Markup { + html! { + div.card-row.issue-row { + span.issue-title { (self.title) } + @for label in &self.labels { + span.chip { (label) } + } + } + } + } +} + /// A signer's stored key is too long for a row, so show a short label beside the /// fingerprint instead of the raw key the structural walk would print. impl Render for Signer {
crates/git-ents-server/src/web/style.css @@ -295,6 +295,8 @@ .card-row.muted { color: var(--color-text-muted); } .card-row.muted code { font-family: var(--font-mono); background: var(--color-code-bg); padding: .1rem .35rem; border-radius: 5px; } .signer-row .key { font-family: var(--font-mono); font-size: .82rem; background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: 5px; padding: .25rem .55rem; } +.issue-row { gap: .55rem; } +.issue-row .issue-title { flex: 1; min-width: 0; font-family: var(--font-sans); color: var(--color-text); } .checks-grid { display: grid; grid-template-columns: 20rem minmax(0, 1fr); gap: 20px; align-items: start; } .checks-grid .card { margin-bottom: 0; }
crates/git-ents/src/issues.rs @@ -1,0 +1,161 @@ +//! The repository's issues, sourced from the `refs/meta/issues/<id>` refs. +//! +//! Each issue is a self-contained typed document on its own ref, +//! `refs/meta/issues/<id>`, read and written through [`git_store`]. One ref per +//! issue keeps issues independently loadable and historied — the ref's commit +//! chain is the issue's edit history — and labels are plain strings so the index +//! can derive its filter set from whatever labels exist, with no separate label +//! registry to keep in sync. + +use std::path::Path; + +use facet::Facet; + +/// The namespace under which issues are recorded: one ref, +/// `refs/meta/issues/<id>`, per issue. +pub const ISSUES_NS: &str = "refs/meta/issues"; + +/// One issue stored at `refs/meta/issues/<id>`. +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +pub struct Issue { + /// The issue's one-line title. + pub title: String, + /// The issue's body text. + pub body: String, + /// The issue's state — `open` or `closed`. + pub state: String, + /// The labels applied to the issue, as plain strings. + pub labels: Vec<String>, + /// The identity that opened the issue. + pub author: String, +} + +impl Issue { + /// Whether the issue is open (any state other than `closed`). + #[must_use] + pub fn is_open(&self) -> bool { + self.state != "closed" + } +} + +/// A failure reading or writing an issue. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// An issue could not be read from or written to its ref. + #[error(transparent)] + Store(#[from] git_store::Error), +} + +/// Load the issue recorded at `refs/meta/issues/<id>` in `repo`, or `None` when +/// no such issue exists. +pub fn load(repo: &Path, id: &str) -> Result<Option<Issue>, Error> { + Ok(git_store::Store::open(repo)?.load::<Issue>(&format!("{ISSUES_NS}/{id}"))?) +} + +/// Write `issue` to `refs/meta/issues/<id>`, replacing any existing value, as a +/// new commit so the ref's commit chain is the issue's edit history. +pub fn store(repo: &Path, id: &str, issue: &Issue) -> Result<(), Error> { + git_store::Store::open(repo)?.store(&format!("{ISSUES_NS}/{id}"), issue, "Update issue")?; + Ok(()) +} + +/// List every issue as `(id, issue)` pairs, newest issue ref first. +pub fn list(repo: &Path) -> Result<Vec<(String, Issue)>, Error> { + let store = git_store::Store::open(repo)?; + let prefix = format!("{ISSUES_NS}/"); + let mut issues = Vec::new(); + for refname in store.list(&prefix)? { + let Some(id) = refname.strip_prefix(&prefix) else { + continue; + }; + if let Some(issue) = store.load::<Issue>(&refname)? { + issues.push((id.to_owned(), issue)); + } + } + Ok(issues) +} + +/// The number of open issues in `repo`. +pub fn open_count(repo: &Path) -> Result<usize, Error> { + Ok(list(repo)? + .into_iter() + .filter(|(_id, issue)| issue.is_open()) + .count()) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::let_underscore_must_use, + reason = "unit test" + )] + + use super::*; + use crate::testutil::{unique_repo as new_repo, write_issue_doc}; + + fn unique_repo() -> std::path::PathBuf { + new_repo("issues") + } + + fn issue(title: &str, state: &str, labels: &[&str]) -> Issue { + Issue { + title: title.to_owned(), + body: "A body".to_owned(), + state: state.to_owned(), + labels: labels.iter().map(|l| (*l).to_owned()).collect(), + author: "alice".to_owned(), + } + } + + #[test] + fn store_then_load_round_trips_an_issue() { + let repo = unique_repo(); + let written = issue("A bug", "open", &["bug", "p1"]); + store(&repo, "1", &written).unwrap(); + assert_eq!(load(&repo, "1").unwrap(), Some(written)); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn none_when_the_issue_is_absent() { + let repo = unique_repo(); + assert_eq!(load(&repo, "1").unwrap(), None); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn lists_issues_and_counts_the_open_ones() { + let repo = unique_repo(); + store(&repo, "1", &issue("Open one", "open", &["bug"])).unwrap(); + store(&repo, "2", &issue("Closed one", "closed", &[])).unwrap(); + let mut ids: Vec<String> = list(&repo).unwrap().into_iter().map(|(id, _)| id).collect(); + ids.sort(); + assert_eq!(ids, vec!["1".to_owned(), "2".to_owned()]); + assert_eq!(open_count(&repo).unwrap(), 1); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn loads_the_on_disk_issue_format() { + // A fixture written as the real on-disk layout — `title`, `body`, + // `state`, `author` blobs plus an index-keyed `labels/` subtree — must + // keep loading, guarding the Issue document's shape against an + // incompatible change to data already on a ref. + let repo = unique_repo(); + write_issue_doc( + &repo, + &format!("{ISSUES_NS}/1"), + "A bug", + "A body", + "open", + &["bug", "p1"], + "alice", + ); + assert_eq!( + load(&repo, "1").unwrap(), + Some(issue("A bug", "open", &["bug", "p1"])) + ); + let _ = std::fs::remove_dir_all(&repo); + } +}