1use anyhow::Result;
5use serde_json::Value;
6use std::fmt::Write as _;
7
8use crate::catalog::{excerpt_from, format_body_excerpt, load_recs, CatalogService};
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(
18 layout: &Layout,
19 project_filter: Option<&str>,
20 state_filter: Option<&str>,
21 ready_only: bool,
22) -> Result<Value> {
23 let recs = load_recs(layout)?;
24 let rows = CatalogService::from_recs(&recs).issues_rows(ListQuery {
25 project: project_filter.map(str::to_string),
26 state: state_filter.map(str::to_string),
27 ready: ready_only,
28 ..ListQuery::default()
29 })?;
30 Ok(serde_json::to_value(rows)?)
31}
32
33pub fn show_json(layout: &Layout, id: &str) -> Result<Value> {
35 let recs = load_recs(layout)?;
36 let detail = CatalogService::from_recs(&recs).detail(id)?;
37 Ok(serde_json::to_value(detail)?)
38}
39
40pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
42 let report = ops::claim(layout, id, force)?;
43 let detail = report::show(layout, id)?;
44 Ok(format!("{report}{detail}"))
45}
46
47pub fn body_excerpt(layout: &Layout, id: &str) -> Result<String> {
49 let recs = load_recs(layout)?;
50 let rec = recs
51 .iter()
52 .find(|r| r.heading.id == id)
53 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
54 Ok(format_body_excerpt(&excerpt_from(rec)?))
55}
56
57pub fn org_text(layout: &Layout, id: &str) -> Result<String> {
63 let recs = load_recs(layout)?;
64 let rec = recs
65 .iter()
66 .find(|r| r.heading.id == id)
67 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
68 let mut text = crate::catalog::org_text_from(rec)?;
69 if !text.ends_with('\n') {
70 text.push('\n');
71 }
72 Ok(text)
73}
74
75pub fn waiting_on(layout: &Layout, id: &str) -> Result<String> {
77 report::backlinks(layout, id)
78}
79
80pub fn hygiene(layout: &Layout, stale_days: Option<i64>) -> Result<String> {
85 let mut out = String::new();
86 writeln!(out, "=== vissue hygiene ===")?;
87
88 let ready_ids: std::collections::HashSet<String> = issues_json(layout, None, None, true)?
91 .as_array()
92 .map(|rows| {
93 rows.iter()
94 .filter_map(|row| row["id"].as_str().map(str::to_string))
95 .collect()
96 })
97 .unwrap_or_default();
98 let mut started_not_ready = 0usize;
99 for (project, h) in load_all(layout)? {
100 if h.state != "STARTED" || ready_ids.contains(&h.id) {
101 continue;
102 }
103 started_not_ready += 1;
104 writeln!(
105 out,
106 "[warn] STARTED but not ready (blockers?): {} ({project}) {}",
107 h.id, h.title
108 )?;
109 }
110
111 let threshold = match stale_days {
112 Some(d) => d,
113 None => {
114 crate::config::VissueConfig::load(layout)?
115 .issues
116 .stale_claim_days
117 }
118 };
119 let today = chrono::Local::now().date_naive();
120 let mut stale_claims = 0usize;
121 let mut unclaimed_started = 0usize;
122 for (project, h) in load_all(layout)? {
123 if h.state != "STARTED" {
124 continue;
125 }
126 match h.claimed_by() {
127 None => {
128 unclaimed_started += 1;
129 writeln!(out, "[warn] STARTED with no claimant: {} ({project})", h.id)?;
130 }
131 Some(who) => {
132 if let Some(days) = h.claim_age_days(today) {
133 if days > threshold {
134 stale_claims += 1;
135 writeln!(
136 out,
137 "[warn] claim held {days}d (over {threshold}d): {} by {who} ({project})",
138 h.id
139 )?;
140 }
141 }
142 }
143 }
144 }
145
146 let check = report::check(layout)?;
147 if check.errors == 0 {
148 writeln!(out, "[ok] check passed")?;
149 } else {
150 writeln!(out, "[fail] check found {} error(s)", check.errors)?;
151 for line in check.text.lines().filter(|l| l.starts_with("[err]")) {
152 writeln!(out, "{line}")?;
153 }
154 }
155 writeln!(
156 out,
157 "summary: started_not_ready={started_not_ready} stale_claims={stale_claims} unclaimed_started={unclaimed_started} projects={} errors={} warnings={}",
158 list_projects(layout)?.len(),
159 check.errors,
160 check.warnings
161 )?;
162 Ok(out)
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use crate::catalog::secret_marker;
169 use crate::config::DEFAULT_PREFIX;
170 use crate::ops::{create, update, CreateOpts};
171 use crate::store::IssueDoc;
172 use std::fs;
173
174 fn layout_with_two_issues() -> (tempfile::TempDir, Layout, String, String) {
175 let dir = tempfile::tempdir().unwrap();
176 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
177 fs::create_dir_all(layout.projects_dir()).unwrap();
178 create(&layout, "sample", "first", CreateOpts::default()).unwrap();
179 create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
180 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
181 let first = doc.headings[0].id.clone();
182 let blocker = doc.headings[1].id.clone();
183 (dir, layout, first, blocker)
184 }
185
186 #[test]
187 fn claim_moves_an_open_issue_to_started() {
188 let (_dir, layout, first, _blocker) = layout_with_two_issues();
189 let text = claim(&layout, &first, false).unwrap();
190 assert!(text.starts_with(&format!("claimed {first}")), "{text}");
191 assert!(text.contains("State: STARTED"), "{text}");
192 }
193
194 #[test]
195 fn claim_refuses_a_closed_issue() {
196 let (_dir, layout, first, _blocker) = layout_with_two_issues();
197 update(&layout, &first, Some("DONE"), None, None, None).unwrap();
198 let err = claim(&layout, &first, false).unwrap_err();
199 assert!(err.to_string().contains("cannot claim"), "{err}");
200 }
201
202 #[test]
203 fn ready_json_drops_blocked_issues() {
204 let (_dir, layout, first, blocker) = layout_with_two_issues();
205 update(&layout, &first, None, None, Some(&blocker), None).unwrap();
206 let rows = issues_json(&layout, None, None, true).unwrap();
207 let ids: Vec<&str> = rows
208 .as_array()
209 .unwrap()
210 .iter()
211 .map(|r| r["id"].as_str().unwrap())
212 .collect();
213 assert_eq!(ids, vec![blocker.as_str()], "{rows}");
214 }
215
216 #[test]
217 fn show_json_carries_the_file_range() {
218 let (_dir, layout, first, _blocker) = layout_with_two_issues();
219 let row = show_json(&layout, &first).unwrap();
220 assert_eq!(row["id"].as_str(), Some(first.as_str()));
221 assert_eq!(row["project"].as_str(), Some("sample"));
222 assert!(
223 row["file"].as_str().unwrap().contains("issues.org:"),
224 "{row}"
225 );
226 }
227
228 #[test]
229 fn hygiene_flags_a_started_issue_that_is_blocked() {
230 let (_dir, layout, first, blocker) = layout_with_two_issues();
231 update(&layout, &first, Some("STARTED"), None, None, None).unwrap();
232 let path = layout.project_issues_path("sample");
234 let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
235 doc.headings
236 .iter_mut()
237 .find(|h| h.id == first)
238 .unwrap()
239 .properties
240 .insert("BLOCKED_BY".into(), blocker.clone());
241 doc.write().unwrap();
242
243 let text = hygiene(&layout, None).unwrap();
244 assert!(text.contains("STARTED but not ready"), "{text}");
245 assert!(text.contains("started_not_ready=1"), "{text}");
246 assert!(text.contains("[ok] check passed"), "{text}");
247 }
248
249 #[test]
250 fn body_excerpt_returns_the_heading_range() {
251 let dir = tempfile::tempdir().unwrap();
252 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
253 fs::create_dir_all(layout.projects_dir()).unwrap();
254 create(
255 &layout,
256 "sample",
257 "documented",
258 CreateOpts {
259 body: Some("Scope: the excerpt path.\nDone-when: it reads back."),
260 ..Default::default()
261 },
262 )
263 .unwrap();
264 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
265 let text = body_excerpt(&layout, &doc.headings[0].id).unwrap();
266 assert!(text.contains("Scope: the excerpt path."), "{text}");
267 assert!(text.contains("Done-when: it reads back."), "{text}");
268 }
269
270 #[test]
271 fn the_secret_screen_reads_shapes_not_substrings() {
272 for carrier in [
274 concat!("-----BEGIN OPENSSH ", "PRIVATE KEY-----"),
277 "aws_secret_access_key = wJalrXUtnFEMI",
278 "Authorization: Bearer abcdefghijklmno",
279 "api_key = 9f8e7d6c5b4a3210ff",
280 "token: ghp_0123456789abcdefghij",
281 "AKIAIOSFODNN7EXAMPLE is the key",
282 ] {
283 assert!(
284 secret_marker(carrier).is_some(),
285 "missed a credential: {carrier:?}"
286 );
287 }
288 for prose in [
291 "Scope: read the header block before the first record.",
292 "making the parser reject a bad manifest",
293 "the task-force agreed on the schema",
294 "deployments in Asia are slower",
295 "next-token: reviewed by the release owner",
296 "Deadline: the parser lands before the notes.",
297 "See the design note for the token grammar.",
298 ] {
299 assert_eq!(secret_marker(prose), None, "false positive: {prose:?}");
300 }
301 }
302
303 #[test]
304 fn body_excerpt_suppresses_apparent_secrets() {
305 let dir = tempfile::tempdir().unwrap();
306 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
307 fs::create_dir_all(layout.projects_dir()).unwrap();
308 create(
309 &layout,
310 "sample",
311 "leaky",
312 CreateOpts {
313 body: Some("token: api_key=whatever-it-was"),
314 ..Default::default()
315 },
316 )
317 .unwrap();
318 let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
319 let text = body_excerpt(&layout, &doc.headings[0].id).unwrap();
320 assert!(text.contains("excerpt suppressed"), "{text}");
321 assert!(!text.contains("whatever-it-was"), "{text}");
322 }
323}