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