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