Skip to main content

vissue_core/
agent.rs

1//! Verbs shaped for a program rather than a person: structured rows, claiming,
2//! a body excerpt, and a hygiene checklist.
3
4use 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
16/// Issue rows as JSON, filtered the same way [`report::list`] filters them.
17///
18/// # Errors
19///
20/// Returns an error if the corpus cannot be read, or the rows cannot be
21/// serialized.
22pub 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
36/// The same rows, still typed.
37///
38/// A caller that has to publish the shape it returns needs the type rather than
39/// the value: a schema taken from `IssueRow` cannot disagree with what this
40/// hands back, and one written beside it can.
41///
42/// # Errors
43///
44/// Returns an error if the corpus cannot be read or the filter is not a state.
45pub 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
60/// One issue as JSON, including its file and line range.
61///
62/// # Errors
63///
64/// Returns an error if the corpus cannot be read, `id` is not in it, or the
65/// detail cannot be serialized.
66pub fn show_json(layout: &Layout, id: &str) -> Result<Value> {
67    Ok(serde_json::to_value(show_detail(layout, id)?)?)
68}
69
70/// The same card, still typed. See [`issues_rows`] for why both exist.
71///
72/// # Errors
73///
74/// Returns an error if the corpus cannot be read or `id` is not in it.
75pub 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
80/// Take an issue: move it to STARTED and stamp the claim.
81///
82/// # Errors
83///
84/// Returns an error if `id` is not in the corpus, the issue is DONE or
85/// CANCELLED, another identity holds it and `force` is false, or the file
86/// cannot be rewritten.
87pub 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
93/// The first lines of an issue's file range, capped and screened for secrets.
94///
95/// # Errors
96///
97/// Returns an error if `id` is not in the corpus, or the heading's file
98/// cannot be read.
99pub 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
108/// One issue's org text, in full, ready to write to a file.
109///
110/// [`body_excerpt`] is a preview and truncates; this does not. It is what a
111/// caller wants when the issue is being handed to someone as the thing to
112/// work from, rather than glanced at.
113///
114/// # Errors
115///
116/// Returns an error if `id` is not in the corpus, the heading's file cannot
117/// be read, or the heading looks like secret material.
118pub 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
131/// Issues waiting on this one.
132///
133/// # Errors
134///
135/// Returns an error if the corpus cannot be read.
136pub fn waiting_on(layout: &Layout, id: &str) -> Result<String> {
137    report::backlinks(layout, id)
138}
139
140/// The agent and CI checklist: issues claimed but not actionable, claims that
141/// have gone stale, plus the corpus validation summary.
142///
143/// `stale_days` overrides the configured threshold when given.
144///
145/// # Errors
146///
147/// Returns an error if the corpus or configuration cannot be read.
148pub fn hygiene(layout: &Layout, stale_days: Option<i64>) -> Result<String> {
149    let mut out = String::new();
150    writeln!(out, "=== vissue hygiene ===")?;
151
152    // Compare ids, not rendered rows: `id_length` is configurable, so one id
153    // can be a prefix of another and a row match would pair the wrong issues.
154    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    // Only where the tracker says the handoff matters. Elsewhere it would
211    // report every answered question and closed review as a hole.
212    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    /// Work that closed naming nothing is a hole in the handoff, but only on a
313    /// tracker that expects one. Reporting it everywhere would flag every
314    /// answered question and closed review, which is how a checklist stops
315    /// being read.
316    #[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        // Blocking would flip the state, so write the edge without the state move.
358        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        // Suppressed: the shapes a credential is actually written in.
398        for carrier in [
399            // Assembled rather than written out: a literal PEM header in a
400            // source file is exactly what the private-key hook looks for.
401            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        // Not suppressed: ordinary prose. A substring screen flags every one
414        // of these -- "making" holds "aki", "task-force" holds "sk-".
415        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}