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 issues_rows_in(&recs, project_filter, state_filter, ready_only)
53}
54
55pub fn issues_rows_in(
63 recs: &[crate::views::IssueRec],
64 project_filter: Option<&str>,
65 state_filter: Option<&str>,
66 ready_only: bool,
67) -> Result<Vec<crate::views::IssueRow>> {
68 CatalogService::from_recs(recs).issues_rows(ListQuery {
69 project: project_filter.map(str::to_string),
70 state: state_filter.map(str::to_string),
71 ready: ready_only,
72 ..ListQuery::default()
73 })
74}
75
76pub fn show_json(layout: &Layout, id: &str) -> Result<Value> {
83 Ok(serde_json::to_value(show_detail(layout, id)?)?)
84}
85
86pub fn show_detail(layout: &Layout, id: &str) -> Result<crate::views::IssueDetail> {
92 let recs = load_recs(layout)?;
93 CatalogService::from_recs(&recs).detail(id)
94}
95
96pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
104 let report = ops::claim(layout, id, force)?;
105 let detail = report::show(layout, id)?;
106 Ok(format!("{report}{detail}"))
107}
108
109pub fn body_excerpt(layout: &Layout, id: &str) -> Result<String> {
116 let recs = load_recs(layout)?;
117 let rec = recs
118 .iter()
119 .find(|r| r.heading.id == id)
120 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
121 Ok(format_body_excerpt(&excerpt_from(rec)?))
122}
123
124pub fn org_text(layout: &Layout, id: &str) -> Result<String> {
135 let recs = load_recs(layout)?;
136 let rec = recs
137 .iter()
138 .find(|r| r.heading.id == id)
139 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
140 let mut text = crate::catalog::org_text_from(rec)?;
141 if !text.ends_with('\n') {
142 text.push('\n');
143 }
144 Ok(text)
145}
146
147pub fn waiting_on(layout: &Layout, id: &str) -> Result<String> {
153 report::backlinks(layout, id)
154}
155
156pub fn hygiene(layout: &Layout, stale_days: Option<i64>) -> Result<String> {
165 let mut out = String::new();
166 writeln!(out, "=== vissue hygiene ===")?;
167 writeln!(
168 out,
169 "[note] agents write through vissue / MCP; never Write or StrReplace issues.org"
170 )?;
171
172 let ready_ids: std::collections::HashSet<String> = issues_json(layout, None, None, true)?
175 .as_array()
176 .map(|rows| {
177 rows.iter()
178 .filter_map(|row| row["id"].as_str().map(str::to_string))
179 .collect()
180 })
181 .unwrap_or_default();
182 let mut started_not_ready = 0usize;
183 for (project, h) in load_all(layout)? {
184 if h.state != "STARTED" || ready_ids.contains(&h.id) {
185 continue;
186 }
187 started_not_ready += 1;
188 writeln!(
189 out,
190 "[warn] STARTED but not ready (blockers?): {} ({project}) {}",
191 h.id, h.title
192 )?;
193 }
194
195 let threshold = match stale_days {
196 Some(d) => d,
197 None => {
198 crate::config::VissueConfig::load(layout)?
199 .issues
200 .stale_claim_days
201 }
202 };
203 let today = chrono::Local::now().date_naive();
204 let mut stale_claims = 0usize;
205 let mut unclaimed_started = 0usize;
206 for (project, h) in load_all(layout)? {
207 if h.state != "STARTED" {
208 continue;
209 }
210 match h.claimed_by() {
211 None => {
212 unclaimed_started += 1;
213 writeln!(out, "[warn] STARTED with no claimant: {} ({project})", h.id)?;
214 }
215 Some(who) => {
216 if let Some(days) = h.claim_age_days(today)
217 && days > threshold
218 {
219 stale_claims += 1;
220 writeln!(
221 out,
222 "[warn] claim held {days}d (over {threshold}d): {} by {who} ({project})",
223 h.id
224 )?;
225 }
226 }
227 }
228 }
229
230 let mut closed_without_a_product = 0usize;
233 if crate::config::VissueConfig::load(layout)?
234 .issues
235 .expect_deeds
236 {
237 for (project, h) in load_all(layout)? {
238 if h.state != "DONE" || !h.deeds().is_empty() {
239 continue;
240 }
241 closed_without_a_product += 1;
242 writeln!(
243 out,
244 "[warn] closed without naming what it made: {} ({project}) {}",
245 h.id, h.title
246 )?;
247 }
248 }
249
250 let check = report::check(layout)?;
251 if check.errors == 0 {
252 writeln!(out, "[ok] check passed")?;
253 } else {
254 writeln!(out, "[fail] check found {} error(s)", check.errors)?;
255 for line in check.text.lines().filter(|l| l.starts_with("[err]")) {
256 writeln!(out, "{line}")?;
257 }
258 }
259 writeln!(
260 out,
261 "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={}",
262 list_projects(layout)?.len(),
263 check.errors,
264 check.warnings
265 )?;
266 Ok(out)
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use crate::catalog::secret_marker;
273 use crate::config::DEFAULT_PREFIX;
274 use crate::ops::{CreateOpts, create, update};
275 use crate::store::IssueDoc;
276 use std::fs;
277
278 fn layout_with_two_issues() -> (tempfile::TempDir, Layout, String, String) {
279 let dir = tempfile::tempdir().unwrap();
280 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
281 fs::create_dir_all(layout.projects_dir()).unwrap();
282 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
283 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
284 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
285 let first = doc.headings[0].id.clone();
286 let blocker = doc.headings[1].id.clone();
287 (dir, layout, first, blocker)
288 }
289
290 #[test]
291 fn claim_moves_an_open_issue_to_started() {
292 let (_dir, layout, first, _blocker) = layout_with_two_issues();
293 let text = claim(&layout, &first, false).unwrap();
294 assert!(text.starts_with(&format!("claimed {first}")), "{text}");
295 assert!(text.contains("State: STARTED"), "{text}");
296 }
297
298 #[test]
299 fn claim_refuses_a_closed_issue() {
300 let (_dir, layout, first, _blocker) = layout_with_two_issues();
301 update(&layout, &first, Some("DONE"), None, None, None).unwrap();
302 let err = claim(&layout, &first, false).unwrap_err();
303 assert!(err.to_string().contains("cannot claim"), "{err}");
304 }
305
306 #[test]
307 fn ready_json_drops_blocked_issues() {
308 let (_dir, layout, first, blocker) = layout_with_two_issues();
309 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
310 let rows = issues_json(&layout, None, None, true).unwrap();
311 let ids: Vec<&str> = rows
312 .as_array()
313 .unwrap()
314 .iter()
315 .map(|r| r["id"].as_str().unwrap())
316 .collect();
317 assert_eq!(ids, vec![blocker.as_str()], "{rows}");
318 }
319
320 #[test]
321 fn show_json_carries_the_file_range() {
322 let (_dir, layout, first, _blocker) = layout_with_two_issues();
323 let row = show_json(&layout, &first).unwrap();
324 assert_eq!(row["id"].as_str(), Some(first.as_str()));
325 assert_eq!(row["project"].as_str(), Some("sample"));
326 assert!(
327 row["file"].as_str().unwrap().contains("issues.org:"),
328 "{row}"
329 );
330 }
331
332 #[test]
337 fn closed_work_that_named_no_product_is_reported_only_where_it_is_expected() {
338 let dir = tempfile::tempdir().unwrap();
339 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
340 fs::create_dir_all(layout.projects_dir()).unwrap();
341 create(&layout, "sample", "made something", CreateOpts::default()).unwrap();
342 create(&layout, "sample", "made nothing", CreateOpts::default()).unwrap();
343 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
344 let (first, second) = (doc.headings[0].id.clone(), doc.headings[1].id.clone());
345 crate::ops::deed(&layout, &first, &["deed-file-thing".to_string()], &[]).unwrap();
346 for id in [&first, &second] {
347 update(&layout, id, Some("DONE"), None, None, None).unwrap();
348 }
349
350 let quiet = hygiene(&layout, None).unwrap();
351 assert!(
352 quiet.contains("closed_without_a_product=0"),
353 "off by default: {quiet}"
354 );
355 assert!(!quiet.contains("closed without naming"), "{quiet}");
356
357 fs::write(
358 dir.path().join("vissue.toml"),
359 "[issues]\nexpect_deeds = true\n",
360 )
361 .unwrap();
362 let strict = hygiene(&layout, None).unwrap();
363 assert!(
364 strict.contains("closed_without_a_product=1"),
365 "one of the two named nothing: {strict}"
366 );
367 assert!(
368 strict.contains(&second) && !strict.contains(&format!("made: {first}")),
369 "the one that cited a deed is not a hole: {strict}"
370 );
371 }
372
373 #[test]
374 fn hygiene_flags_a_started_issue_that_is_blocked() {
375 let (_dir, layout, first, blocker) = layout_with_two_issues();
376 update(&layout, &first, Some("STARTED"), None, None, None).unwrap();
377 let path = layout.project_issues_path("sample");
379 let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
380 doc.headings
381 .iter_mut()
382 .find(|h| h.id == first)
383 .unwrap()
384 .properties
385 .insert("BLOCKED_BY".into(), blocker.clone());
386 doc.write().unwrap();
387
388 let text = hygiene(&layout, None).unwrap();
389 assert!(
390 text.contains("never Write or StrReplace issues.org"),
391 "{text}"
392 );
393 assert!(text.contains("STARTED but not ready"), "{text}");
394 assert!(text.contains("started_not_ready=1"), "{text}");
395 assert!(text.contains("[ok] check passed"), "{text}");
396 }
397
398 #[test]
399 fn body_excerpt_returns_the_heading_range() {
400 let dir = tempfile::tempdir().unwrap();
401 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
402 fs::create_dir_all(layout.projects_dir()).unwrap();
403 create(
404 &layout,
405 "sample",
406 "documented",
407 CreateOpts {
408 body: Some("Scope: the excerpt path.\nDone-when: it reads back."),
409 ..Default::default()
410 },
411 )
412 .unwrap();
413 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
414 let text = body_excerpt(&layout, &doc.headings[0].id).unwrap();
415 assert!(text.contains("Scope: the excerpt path."), "{text}");
416 assert!(text.contains("Done-when: it reads back."), "{text}");
417 }
418
419 #[test]
420 fn the_secret_screen_reads_shapes_not_substrings() {
421 for carrier in [
423 concat!("-----BEGIN OPENSSH ", "PRIVATE KEY-----"),
426 "aws_secret_access_key = wJalrXUtnFEMI",
427 "Authorization: Bearer abcdefghijklmno",
428 "api_key = 9f8e7d6c5b4a3210ff",
429 "token: ghp_0123456789abcdefghij",
430 "AKIAIOSFODNN7EXAMPLE is the key",
431 ] {
432 assert!(
433 secret_marker(carrier).is_some(),
434 "missed a credential: {carrier:?}"
435 );
436 }
437 for prose in [
440 "Scope: read the header block before the first record.",
441 "making the parser reject a bad manifest",
442 "the task-force agreed on the schema",
443 "deployments in Asia are slower",
444 "next-token: reviewed by the release owner",
445 "Deadline: the parser lands before the notes.",
446 "See the design note for the token grammar.",
447 ] {
448 assert_eq!(secret_marker(prose), None, "false positive: {prose:?}");
449 }
450 }
451
452 #[test]
453 fn body_excerpt_suppresses_apparent_secrets() {
454 let dir = tempfile::tempdir().unwrap();
455 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
456 fs::create_dir_all(layout.projects_dir()).unwrap();
457 create(
458 &layout,
459 "sample",
460 "leaky",
461 CreateOpts {
462 body: Some("token: api_key=whatever-it-was"),
463 ..Default::default()
464 },
465 )
466 .unwrap();
467 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
468 let text = body_excerpt(&layout, &doc.headings[0].id).unwrap();
469 assert!(text.contains("excerpt suppressed"), "{text}");
470 assert!(!text.contains("whatever-it-was"), "{text}");
471 }
472}