use crate::error::Result;
use serde_json::Value;
use std::fmt::Write as _;
use crate::catalog::{CatalogService, excerpt_from, format_body_excerpt, load_recs};
use crate::config::Layout;
use crate::error::Error;
use crate::ops;
use crate::report;
use crate::store::{list_projects, load_all};
use crate::views::ListQuery;
pub fn issues_json(
layout: &Layout,
project_filter: Option<&str>,
state_filter: Option<&str>,
ready_only: bool,
) -> Result<Value> {
let recs = load_recs(layout)?;
let rows = CatalogService::from_recs(&recs).issues_rows(ListQuery {
project: project_filter.map(str::to_string),
state: state_filter.map(str::to_string),
ready: ready_only,
..ListQuery::default()
})?;
Ok(serde_json::to_value(rows)?)
}
pub fn show_json(layout: &Layout, id: &str) -> Result<Value> {
let recs = load_recs(layout)?;
let detail = CatalogService::from_recs(&recs).detail(id)?;
Ok(serde_json::to_value(detail)?)
}
pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
let report = ops::claim(layout, id, force)?;
let detail = report::show(layout, id)?;
Ok(format!("{report}{detail}"))
}
pub fn body_excerpt(layout: &Layout, id: &str) -> Result<String> {
let recs = load_recs(layout)?;
let rec = recs
.iter()
.find(|r| r.heading.id == id)
.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
Ok(format_body_excerpt(&excerpt_from(rec)?))
}
pub fn org_text(layout: &Layout, id: &str) -> Result<String> {
let recs = load_recs(layout)?;
let rec = recs
.iter()
.find(|r| r.heading.id == id)
.ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
let mut text = crate::catalog::org_text_from(rec)?;
if !text.ends_with('\n') {
text.push('\n');
}
Ok(text)
}
pub fn waiting_on(layout: &Layout, id: &str) -> Result<String> {
report::backlinks(layout, id)
}
pub fn hygiene(layout: &Layout, stale_days: Option<i64>) -> Result<String> {
let mut out = String::new();
writeln!(out, "=== vissue hygiene ===")?;
let ready_ids: std::collections::HashSet<String> = issues_json(layout, None, None, true)?
.as_array()
.map(|rows| {
rows.iter()
.filter_map(|row| row["id"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let mut started_not_ready = 0usize;
for (project, h) in load_all(layout)? {
if h.state != "STARTED" || ready_ids.contains(&h.id) {
continue;
}
started_not_ready += 1;
writeln!(
out,
"[warn] STARTED but not ready (blockers?): {} ({project}) {}",
h.id, h.title
)?;
}
let threshold = match stale_days {
Some(d) => d,
None => {
crate::config::VissueConfig::load(layout)?
.issues
.stale_claim_days
}
};
let today = chrono::Local::now().date_naive();
let mut stale_claims = 0usize;
let mut unclaimed_started = 0usize;
for (project, h) in load_all(layout)? {
if h.state != "STARTED" {
continue;
}
match h.claimed_by() {
None => {
unclaimed_started += 1;
writeln!(out, "[warn] STARTED with no claimant: {} ({project})", h.id)?;
}
Some(who) => {
if let Some(days) = h.claim_age_days(today)
&& days > threshold
{
stale_claims += 1;
writeln!(
out,
"[warn] claim held {days}d (over {threshold}d): {} by {who} ({project})",
h.id
)?;
}
}
}
}
let mut closed_without_a_product = 0usize;
if crate::config::VissueConfig::load(layout)?
.issues
.expect_deeds
{
for (project, h) in load_all(layout)? {
if h.state != "DONE" || !h.deeds().is_empty() {
continue;
}
closed_without_a_product += 1;
writeln!(
out,
"[warn] closed without naming what it made: {} ({project}) {}",
h.id, h.title
)?;
}
}
let check = report::check(layout)?;
if check.errors == 0 {
writeln!(out, "[ok] check passed")?;
} else {
writeln!(out, "[fail] check found {} error(s)", check.errors)?;
for line in check.text.lines().filter(|l| l.starts_with("[err]")) {
writeln!(out, "{line}")?;
}
}
writeln!(
out,
"summary: started_not_ready={started_not_ready} stale_claims={stale_claims} unclaimed_started={unclaimed_started} closed_without_a_product={closed_without_a_product} projects={} errors={} warnings={}",
list_projects(layout)?.len(),
check.errors,
check.warnings
)?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::secret_marker;
use crate::config::DEFAULT_PREFIX;
use crate::ops::{CreateOpts, create, update};
use crate::store::IssueDoc;
use std::fs;
fn layout_with_two_issues() -> (tempfile::TempDir, Layout, String, String) {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
fs::create_dir_all(layout.projects_dir()).unwrap();
create(&layout, "sample", "first", CreateOpts::default()).unwrap();
create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
let first = doc.headings[0].id.clone();
let blocker = doc.headings[1].id.clone();
(dir, layout, first, blocker)
}
#[test]
fn claim_moves_an_open_issue_to_started() {
let (_dir, layout, first, _blocker) = layout_with_two_issues();
let text = claim(&layout, &first, false).unwrap();
assert!(text.starts_with(&format!("claimed {first}")), "{text}");
assert!(text.contains("State: STARTED"), "{text}");
}
#[test]
fn claim_refuses_a_closed_issue() {
let (_dir, layout, first, _blocker) = layout_with_two_issues();
update(&layout, &first, Some("DONE"), None, None, None).unwrap();
let err = claim(&layout, &first, false).unwrap_err();
assert!(err.to_string().contains("cannot claim"), "{err}");
}
#[test]
fn ready_json_drops_blocked_issues() {
let (_dir, layout, first, blocker) = layout_with_two_issues();
update(&layout, &first, None, None, Some(&blocker), None).unwrap();
let rows = issues_json(&layout, None, None, true).unwrap();
let ids: Vec<&str> = rows
.as_array()
.unwrap()
.iter()
.map(|r| r["id"].as_str().unwrap())
.collect();
assert_eq!(ids, vec![blocker.as_str()], "{rows}");
}
#[test]
fn show_json_carries_the_file_range() {
let (_dir, layout, first, _blocker) = layout_with_two_issues();
let row = show_json(&layout, &first).unwrap();
assert_eq!(row["id"].as_str(), Some(first.as_str()));
assert_eq!(row["project"].as_str(), Some("sample"));
assert!(
row["file"].as_str().unwrap().contains("issues.org:"),
"{row}"
);
}
#[test]
fn closed_work_that_named_no_product_is_reported_only_where_it_is_expected() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
fs::create_dir_all(layout.projects_dir()).unwrap();
create(&layout, "sample", "made something", CreateOpts::default()).unwrap();
create(&layout, "sample", "made nothing", CreateOpts::default()).unwrap();
let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
let (first, second) = (doc.headings[0].id.clone(), doc.headings[1].id.clone());
crate::ops::deed(&layout, &first, &["deed-file-thing".to_string()], &[]).unwrap();
for id in [&first, &second] {
update(&layout, id, Some("DONE"), None, None, None).unwrap();
}
let quiet = hygiene(&layout, None).unwrap();
assert!(
quiet.contains("closed_without_a_product=0"),
"off by default: {quiet}"
);
assert!(!quiet.contains("closed without naming"), "{quiet}");
fs::write(
dir.path().join("vissue.toml"),
"[issues]\nexpect_deeds = true\n",
)
.unwrap();
let strict = hygiene(&layout, None).unwrap();
assert!(
strict.contains("closed_without_a_product=1"),
"one of the two named nothing: {strict}"
);
assert!(
strict.contains(&second) && !strict.contains(&format!("made: {first}")),
"the one that cited a deed is not a hole: {strict}"
);
}
#[test]
fn hygiene_flags_a_started_issue_that_is_blocked() {
let (_dir, layout, first, blocker) = layout_with_two_issues();
update(&layout, &first, Some("STARTED"), None, None, None).unwrap();
let path = layout.project_issues_path("sample");
let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
doc.headings
.iter_mut()
.find(|h| h.id == first)
.unwrap()
.properties
.insert("BLOCKED_BY".into(), blocker.clone());
doc.write().unwrap();
let text = hygiene(&layout, None).unwrap();
assert!(text.contains("STARTED but not ready"), "{text}");
assert!(text.contains("started_not_ready=1"), "{text}");
assert!(text.contains("[ok] check passed"), "{text}");
}
#[test]
fn body_excerpt_returns_the_heading_range() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
fs::create_dir_all(layout.projects_dir()).unwrap();
create(
&layout,
"sample",
"documented",
CreateOpts {
body: Some("Scope: the excerpt path.\nDone-when: it reads back."),
..Default::default()
},
)
.unwrap();
let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
let text = body_excerpt(&layout, &doc.headings[0].id).unwrap();
assert!(text.contains("Scope: the excerpt path."), "{text}");
assert!(text.contains("Done-when: it reads back."), "{text}");
}
#[test]
fn the_secret_screen_reads_shapes_not_substrings() {
for carrier in [
concat!("-----BEGIN OPENSSH ", "PRIVATE KEY-----"),
"aws_secret_access_key = wJalrXUtnFEMI",
"Authorization: Bearer abcdefghijklmno",
"api_key = 9f8e7d6c5b4a3210ff",
"token: ghp_0123456789abcdefghij",
"AKIAIOSFODNN7EXAMPLE is the key",
] {
assert!(
secret_marker(carrier).is_some(),
"missed a credential: {carrier:?}"
);
}
for prose in [
"Scope: read the header block before the first record.",
"making the parser reject a bad manifest",
"the task-force agreed on the schema",
"deployments in Asia are slower",
"next-token: reviewed by the release owner",
"Deadline: the parser lands before the notes.",
"See the design note for the token grammar.",
] {
assert_eq!(secret_marker(prose), None, "false positive: {prose:?}");
}
}
#[test]
fn body_excerpt_suppresses_apparent_secrets() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
fs::create_dir_all(layout.projects_dir()).unwrap();
create(
&layout,
"sample",
"leaky",
CreateOpts {
body: Some("token: api_key=whatever-it-was"),
..Default::default()
},
)
.unwrap();
let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
let text = body_excerpt(&layout, &doc.headings[0].id).unwrap();
assert!(text.contains("excerpt suppressed"), "{text}");
assert!(!text.contains("whatever-it-was"), "{text}");
}
}