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    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
38/// One issue as JSON, including its file and line range.
39///
40/// # Errors
41///
42/// Returns an error if the corpus cannot be read, `id` is not in it, or the
43/// detail cannot be serialized.
44pub 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
50/// Take an issue: move it to STARTED and stamp the claim.
51///
52/// # Errors
53///
54/// Returns an error if `id` is not in the corpus, the issue is DONE or
55/// CANCELLED, another identity holds it and `force` is false, or the file
56/// cannot be rewritten.
57pub 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
63/// The first lines of an issue's file range, capped and screened for secrets.
64///
65/// # Errors
66///
67/// Returns an error if `id` is not in the corpus, or the heading's file
68/// cannot be read.
69pub 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
78/// One issue's org text, in full, ready to write to a file.
79///
80/// [`body_excerpt`] is a preview and truncates; this does not. It is what a
81/// caller wants when the issue is being handed to someone as the thing to
82/// work from, rather than glanced at.
83///
84/// # Errors
85///
86/// Returns an error if `id` is not in the corpus, the heading's file cannot
87/// be read, or the heading looks like secret material.
88pub 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
101/// Issues waiting on this one.
102///
103/// # Errors
104///
105/// Returns an error if the corpus cannot be read.
106pub fn waiting_on(layout: &Layout, id: &str) -> Result<String> {
107    report::backlinks(layout, id)
108}
109
110/// The agent and CI checklist: issues claimed but not actionable, claims that
111/// have gone stale, plus the corpus validation summary.
112///
113/// `stale_days` overrides the configured threshold when given.
114///
115/// # Errors
116///
117/// Returns an error if the corpus or configuration cannot be read.
118pub fn hygiene(layout: &Layout, stale_days: Option<i64>) -> Result<String> {
119    let mut out = String::new();
120    writeln!(out, "=== vissue hygiene ===")?;
121
122    // Compare ids, not rendered rows: `id_length` is configurable, so one id
123    // can be a prefix of another and a row match would pair the wrong issues.
124    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    // Only where the tracker says the handoff matters. Elsewhere it would
181    // report every answered question and closed review as a hole.
182    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    /// Work that closed naming nothing is a hole in the handoff, but only on a
283    /// tracker that expects one. Reporting it everywhere would flag every
284    /// answered question and closed review, which is how a checklist stops
285    /// being read.
286    #[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        // Blocking would flip the state, so write the edge without the state move.
328        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        // Suppressed: the shapes a credential is actually written in.
368        for carrier in [
369            // Assembled rather than written out: a literal PEM header in a
370            // source file is exactly what the private-key hook looks for.
371            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        // Not suppressed: ordinary prose. A substring screen flags every one
384        // of these -- "making" holds "aki", "task-force" holds "sk-".
385        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}