1use crate::error::Result;
5use serde_json::Value;
6use std::fmt::Write as _;
7
8use crate::catalog::{CatalogService, excerpt_from, format_body_excerpt, load_recs};
9use crate::config::Layout;
10use crate::error::Error;
11use crate::ops;
12use crate::report;
13use crate::store::{list_projects, load_all};
14use crate::views::ListQuery;
15
16pub fn issues_json(
23 layout: &Layout,
24 project_filter: Option<&str>,
25 state_filter: Option<&str>,
26 ready_only: bool,
27) -> Result<Value> {
28 Ok(serde_json::to_value(issues_rows(
29 layout,
30 project_filter,
31 state_filter,
32 ready_only,
33 )?)?)
34}
35
36pub fn issues_rows(
46 layout: &Layout,
47 project_filter: Option<&str>,
48 state_filter: Option<&str>,
49 ready_only: bool,
50) -> Result<Vec<crate::views::IssueRow>> {
51 let recs = load_recs(layout)?;
52 CatalogService::from_recs(&recs).issues_rows(ListQuery {
53 project: project_filter.map(str::to_string),
54 state: state_filter.map(str::to_string),
55 ready: ready_only,
56 ..ListQuery::default()
57 })
58}
59
60pub fn show_json(layout: &Layout, id: &str) -> Result<Value> {
67 Ok(serde_json::to_value(show_detail(layout, id)?)?)
68}
69
70pub fn show_detail(layout: &Layout, id: &str) -> Result<crate::views::IssueDetail> {
76 let recs = load_recs(layout)?;
77 CatalogService::from_recs(&recs).detail(id)
78}
79
80pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
88 let report = ops::claim(layout, id, force)?;
89 let detail = report::show(layout, id)?;
90 Ok(format!("{report}{detail}"))
91}
92
93pub fn body_excerpt(layout: &Layout, id: &str) -> Result<String> {
100 let recs = load_recs(layout)?;
101 let rec = recs
102 .iter()
103 .find(|r| r.heading.id == id)
104 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
105 Ok(format_body_excerpt(&excerpt_from(rec)?))
106}
107
108pub fn org_text(layout: &Layout, id: &str) -> Result<String> {
119 let recs = load_recs(layout)?;
120 let rec = recs
121 .iter()
122 .find(|r| r.heading.id == id)
123 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
124 let mut text = crate::catalog::org_text_from(rec)?;
125 if !text.ends_with('\n') {
126 text.push('\n');
127 }
128 Ok(text)
129}
130
131pub fn waiting_on(layout: &Layout, id: &str) -> Result<String> {
137 report::backlinks(layout, id)
138}
139
140pub fn hygiene(layout: &Layout, stale_days: Option<i64>) -> Result<String> {
149 let mut out = String::new();
150 writeln!(out, "=== vissue hygiene ===")?;
151
152 let ready_ids: std::collections::HashSet<String> = issues_json(layout, None, None, true)?
155 .as_array()
156 .map(|rows| {
157 rows.iter()
158 .filter_map(|row| row["id"].as_str().map(str::to_string))
159 .collect()
160 })
161 .unwrap_or_default();
162 let mut started_not_ready = 0usize;
163 for (project, h) in load_all(layout)? {
164 if h.state != "STARTED" || ready_ids.contains(&h.id) {
165 continue;
166 }
167 started_not_ready += 1;
168 writeln!(
169 out,
170 "[warn] STARTED but not ready (blockers?): {} ({project}) {}",
171 h.id, h.title
172 )?;
173 }
174
175 let threshold = match stale_days {
176 Some(d) => d,
177 None => {
178 crate::config::VissueConfig::load(layout)?
179 .issues
180 .stale_claim_days
181 }
182 };
183 let today = chrono::Local::now().date_naive();
184 let mut stale_claims = 0usize;
185 let mut unclaimed_started = 0usize;
186 for (project, h) in load_all(layout)? {
187 if h.state != "STARTED" {
188 continue;
189 }
190 match h.claimed_by() {
191 None => {
192 unclaimed_started += 1;
193 writeln!(out, "[warn] STARTED with no claimant: {} ({project})", h.id)?;
194 }
195 Some(who) => {
196 if let Some(days) = h.claim_age_days(today)
197 && days > threshold
198 {
199 stale_claims += 1;
200 writeln!(
201 out,
202 "[warn] claim held {days}d (over {threshold}d): {} by {who} ({project})",
203 h.id
204 )?;
205 }
206 }
207 }
208 }
209
210 let mut closed_without_a_product = 0usize;
213 if crate::config::VissueConfig::load(layout)?
214 .issues
215 .expect_deeds
216 {
217 for (project, h) in load_all(layout)? {
218 if h.state != "DONE" || !h.deeds().is_empty() {
219 continue;
220 }
221 closed_without_a_product += 1;
222 writeln!(
223 out,
224 "[warn] closed without naming what it made: {} ({project}) {}",
225 h.id, h.title
226 )?;
227 }
228 }
229
230 let check = report::check(layout)?;
231 if check.errors == 0 {
232 writeln!(out, "[ok] check passed")?;
233 } else {
234 writeln!(out, "[fail] check found {} error(s)", check.errors)?;
235 for line in check.text.lines().filter(|l| l.starts_with("[err]")) {
236 writeln!(out, "{line}")?;
237 }
238 }
239 writeln!(
240 out,
241 "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={}",
242 list_projects(layout)?.len(),
243 check.errors,
244 check.warnings
245 )?;
246 Ok(out)
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::catalog::secret_marker;
253 use crate::config::DEFAULT_PREFIX;
254 use crate::ops::{CreateOpts, create, update};
255 use crate::store::IssueDoc;
256 use std::fs;
257
258 fn layout_with_two_issues() -> (tempfile::TempDir, Layout, String, String) {
259 let dir = tempfile::tempdir().unwrap();
260 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
261 fs::create_dir_all(layout.projects_dir()).unwrap();
262 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
263 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
264 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
265 let first = doc.headings[0].id.clone();
266 let blocker = doc.headings[1].id.clone();
267 (dir, layout, first, blocker)
268 }
269
270 #[test]
271 fn claim_moves_an_open_issue_to_started() {
272 let (_dir, layout, first, _blocker) = layout_with_two_issues();
273 let text = claim(&layout, &first, false).unwrap();
274 assert!(text.starts_with(&format!("claimed {first}")), "{text}");
275 assert!(text.contains("State: STARTED"), "{text}");
276 }
277
278 #[test]
279 fn claim_refuses_a_closed_issue() {
280 let (_dir, layout, first, _blocker) = layout_with_two_issues();
281 update(&layout, &first, Some("DONE"), None, None, None).unwrap();
282 let err = claim(&layout, &first, false).unwrap_err();
283 assert!(err.to_string().contains("cannot claim"), "{err}");
284 }
285
286 #[test]
287 fn ready_json_drops_blocked_issues() {
288 let (_dir, layout, first, blocker) = layout_with_two_issues();
289 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
290 let rows = issues_json(&layout, None, None, true).unwrap();
291 let ids: Vec<&str> = rows
292 .as_array()
293 .unwrap()
294 .iter()
295 .map(|r| r["id"].as_str().unwrap())
296 .collect();
297 assert_eq!(ids, vec![blocker.as_str()], "{rows}");
298 }
299
300 #[test]
301 fn show_json_carries_the_file_range() {
302 let (_dir, layout, first, _blocker) = layout_with_two_issues();
303 let row = show_json(&layout, &first).unwrap();
304 assert_eq!(row["id"].as_str(), Some(first.as_str()));
305 assert_eq!(row["project"].as_str(), Some("sample"));
306 assert!(
307 row["file"].as_str().unwrap().contains("issues.org:"),
308 "{row}"
309 );
310 }
311
312 #[test]
317 fn closed_work_that_named_no_product_is_reported_only_where_it_is_expected() {
318 let dir = tempfile::tempdir().unwrap();
319 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
320 fs::create_dir_all(layout.projects_dir()).unwrap();
321 create(&layout, "sample", "made something", CreateOpts::default()).unwrap();
322 create(&layout, "sample", "made nothing", CreateOpts::default()).unwrap();
323 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
324 let (first, second) = (doc.headings[0].id.clone(), doc.headings[1].id.clone());
325 crate::ops::deed(&layout, &first, &["deed-file-thing".to_string()], &[]).unwrap();
326 for id in [&first, &second] {
327 update(&layout, id, Some("DONE"), None, None, None).unwrap();
328 }
329
330 let quiet = hygiene(&layout, None).unwrap();
331 assert!(
332 quiet.contains("closed_without_a_product=0"),
333 "off by default: {quiet}"
334 );
335 assert!(!quiet.contains("closed without naming"), "{quiet}");
336
337 fs::write(
338 dir.path().join("vissue.toml"),
339 "[issues]\nexpect_deeds = true\n",
340 )
341 .unwrap();
342 let strict = hygiene(&layout, None).unwrap();
343 assert!(
344 strict.contains("closed_without_a_product=1"),
345 "one of the two named nothing: {strict}"
346 );
347 assert!(
348 strict.contains(&second) && !strict.contains(&format!("made: {first}")),
349 "the one that cited a deed is not a hole: {strict}"
350 );
351 }
352
353 #[test]
354 fn hygiene_flags_a_started_issue_that_is_blocked() {
355 let (_dir, layout, first, blocker) = layout_with_two_issues();
356 update(&layout, &first, Some("STARTED"), None, None, None).unwrap();
357 let path = layout.project_issues_path("sample");
359 let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
360 doc.headings
361 .iter_mut()
362 .find(|h| h.id == first)
363 .unwrap()
364 .properties
365 .insert("BLOCKED_BY".into(), blocker.clone());
366 doc.write().unwrap();
367
368 let text = hygiene(&layout, None).unwrap();
369 assert!(text.contains("STARTED but not ready"), "{text}");
370 assert!(text.contains("started_not_ready=1"), "{text}");
371 assert!(text.contains("[ok] check passed"), "{text}");
372 }
373
374 #[test]
375 fn body_excerpt_returns_the_heading_range() {
376 let dir = tempfile::tempdir().unwrap();
377 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
378 fs::create_dir_all(layout.projects_dir()).unwrap();
379 create(
380 &layout,
381 "sample",
382 "documented",
383 CreateOpts {
384 body: Some("Scope: the excerpt path.\nDone-when: it reads back."),
385 ..Default::default()
386 },
387 )
388 .unwrap();
389 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
390 let text = body_excerpt(&layout, &doc.headings[0].id).unwrap();
391 assert!(text.contains("Scope: the excerpt path."), "{text}");
392 assert!(text.contains("Done-when: it reads back."), "{text}");
393 }
394
395 #[test]
396 fn the_secret_screen_reads_shapes_not_substrings() {
397 for carrier in [
399 concat!("-----BEGIN OPENSSH ", "PRIVATE KEY-----"),
402 "aws_secret_access_key = wJalrXUtnFEMI",
403 "Authorization: Bearer abcdefghijklmno",
404 "api_key = 9f8e7d6c5b4a3210ff",
405 "token: ghp_0123456789abcdefghij",
406 "AKIAIOSFODNN7EXAMPLE is the key",
407 ] {
408 assert!(
409 secret_marker(carrier).is_some(),
410 "missed a credential: {carrier:?}"
411 );
412 }
413 for prose in [
416 "Scope: read the header block before the first record.",
417 "making the parser reject a bad manifest",
418 "the task-force agreed on the schema",
419 "deployments in Asia are slower",
420 "next-token: reviewed by the release owner",
421 "Deadline: the parser lands before the notes.",
422 "See the design note for the token grammar.",
423 ] {
424 assert_eq!(secret_marker(prose), None, "false positive: {prose:?}");
425 }
426 }
427
428 #[test]
429 fn body_excerpt_suppresses_apparent_secrets() {
430 let dir = tempfile::tempdir().unwrap();
431 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
432 fs::create_dir_all(layout.projects_dir()).unwrap();
433 create(
434 &layout,
435 "sample",
436 "leaky",
437 CreateOpts {
438 body: Some("token: api_key=whatever-it-was"),
439 ..Default::default()
440 },
441 )
442 .unwrap();
443 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
444 let text = body_excerpt(&layout, &doc.headings[0].id).unwrap();
445 assert!(text.contains("excerpt suppressed"), "{text}");
446 assert!(!text.contains("whatever-it-was"), "{text}");
447 }
448}