feat: make the Issues tab functional via refs/meta/issues docs
commit
c7852a3feat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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);
+ }
+}