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    issues_rows_in(&recs, project_filter, state_filter, ready_only)
53}
54
55/// [`issues_rows`] over a corpus the caller already holds, so a caller asking
56/// about every project of one tracker loads it once rather than once per
57/// project.
58///
59/// # Errors
60///
61/// Does not fail for a parsed corpus.
62pub fn issues_rows_in(
63    recs: &[crate::views::IssueRec],
64    project_filter: Option<&str>,
65    state_filter: Option<&str>,
66    ready_only: bool,
67) -> Result<Vec<crate::views::IssueRow>> {
68    CatalogService::from_recs(recs).issues_rows(ListQuery {
69        project: project_filter.map(str::to_string),
70        state: state_filter.map(str::to_string),
71        ready: ready_only,
72        ..ListQuery::default()
73    })
74}
75
76/// One issue as JSON, including its file and line range.
77///
78/// # Errors
79///
80/// Returns an error if the corpus cannot be read, `id` is not in it, or the
81/// detail cannot be serialized.
82pub fn show_json(layout: &Layout, id: &str) -> Result<Value> {
83    Ok(serde_json::to_value(show_detail(layout, id)?)?)
84}
85
86/// The same card, still typed. See [`issues_rows`] for why both exist.
87///
88/// # Errors
89///
90/// Returns an error if the corpus cannot be read or `id` is not in it.
91pub fn show_detail(layout: &Layout, id: &str) -> Result<crate::views::IssueDetail> {
92    let recs = load_recs(layout)?;
93    CatalogService::from_recs(&recs).detail(id)
94}
95
96/// Take an issue: move it to STARTED and stamp the claim.
97///
98/// # Errors
99///
100/// Returns an error if `id` is not in the corpus, the issue is DONE or
101/// CANCELLED, another identity holds it and `force` is false, or the file
102/// cannot be rewritten.
103pub fn claim(layout: &Layout, id: &str, force: bool) -> Result<String> {
104    let report = ops::claim(layout, id, force)?;
105    let detail = report::show(layout, id)?;
106    Ok(format!("{report}{detail}"))
107}
108
109/// The first lines of an issue's file range, capped and screened for secrets.
110///
111/// # Errors
112///
113/// Returns an error if `id` is not in the corpus, or the heading's file
114/// cannot be read.
115pub fn body_excerpt(layout: &Layout, id: &str) -> Result<String> {
116    let recs = load_recs(layout)?;
117    let rec = recs
118        .iter()
119        .find(|r| r.heading.id == id)
120        .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
121    Ok(format_body_excerpt(&excerpt_from(rec)?))
122}
123
124/// One issue's org text, in full, ready to write to a file.
125///
126/// [`body_excerpt`] is a preview and truncates; this does not. It is what a
127/// caller wants when the issue is being handed to someone as the thing to
128/// work from, rather than glanced at.
129///
130/// # Errors
131///
132/// Returns an error if `id` is not in the corpus, the heading's file cannot
133/// be read, or the heading looks like secret material.
134pub fn org_text(layout: &Layout, id: &str) -> Result<String> {
135    let recs = load_recs(layout)?;
136    let rec = recs
137        .iter()
138        .find(|r| r.heading.id == id)
139        .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
140    let mut text = crate::catalog::org_text_from(rec)?;
141    if !text.ends_with('\n') {
142        text.push('\n');
143    }
144    Ok(text)
145}
146
147/// Issues waiting on this one.
148///
149/// # Errors
150///
151/// Returns an error if the corpus cannot be read.
152pub fn waiting_on(layout: &Layout, id: &str) -> Result<String> {
153    report::backlinks(layout, id)
154}
155
156/// The agent and CI checklist: issues claimed but not actionable, claims that
157/// have gone stale, plus the corpus validation summary.
158///
159/// `stale_days` overrides the configured threshold when given.
160///
161/// # Errors
162///
163/// Returns an error if the corpus or configuration cannot be read.
164pub fn hygiene(layout: &Layout, stale_days: Option<i64>) -> Result<String> {
165    let mut out = String::new();
166    writeln!(out, "=== vissue hygiene ===")?;
167    writeln!(
168        out,
169        "[note] agents write through vissue / MCP; never Write or StrReplace issues.org"
170    )?;
171
172    // Compare ids, not rendered rows: `id_length` is configurable, so one id
173    // can be a prefix of another and a row match would pair the wrong issues.
174    let ready_ids: std::collections::HashSet<String> = issues_json(layout, None, None, true)?
175        .as_array()
176        .map(|rows| {
177            rows.iter()
178                .filter_map(|row| row["id"].as_str().map(str::to_string))
179                .collect()
180        })
181        .unwrap_or_default();
182    let mut started_not_ready = 0usize;
183    for (project, h) in load_all(layout)? {
184        if h.state != "STARTED" || ready_ids.contains(&h.id) {
185            continue;
186        }
187        started_not_ready += 1;
188        writeln!(
189            out,
190            "[warn] STARTED but not ready (blockers?): {} ({project})  {}",
191            h.id, h.title
192        )?;
193    }
194
195    let threshold = match stale_days {
196        Some(d) => d,
197        None => {
198            crate::config::VissueConfig::load(layout)?
199                .issues
200                .stale_claim_days
201        }
202    };
203    let today = chrono::Local::now().date_naive();
204    let mut stale_claims = 0usize;
205    let mut unclaimed_started = 0usize;
206    for (project, h) in load_all(layout)? {
207        if h.state != "STARTED" {
208            continue;
209        }
210        match h.claimed_by() {
211            None => {
212                unclaimed_started += 1;
213                writeln!(out, "[warn] STARTED with no claimant: {} ({project})", h.id)?;
214            }
215            Some(who) => {
216                if let Some(days) = h.claim_age_days(today)
217                    && days > threshold
218                {
219                    stale_claims += 1;
220                    writeln!(
221                        out,
222                        "[warn] claim held {days}d (over {threshold}d): {} by {who} ({project})",
223                        h.id
224                    )?;
225                }
226            }
227        }
228    }
229
230    // Only where the tracker says the handoff matters. Elsewhere it would
231    // report every answered question and closed review as a hole.
232    let mut closed_without_a_product = 0usize;
233    if crate::config::VissueConfig::load(layout)?
234        .issues
235        .expect_deeds
236    {
237        for (project, h) in load_all(layout)? {
238            if h.state != "DONE" || !h.deeds().is_empty() {
239                continue;
240            }
241            closed_without_a_product += 1;
242            writeln!(
243                out,
244                "[warn] closed without naming what it made: {} ({project})  {}",
245                h.id, h.title
246            )?;
247        }
248    }
249
250    let check = report::check(layout)?;
251    if check.errors == 0 {
252        writeln!(out, "[ok] check passed")?;
253    } else {
254        writeln!(out, "[fail] check found {} error(s)", check.errors)?;
255        for line in check.text.lines().filter(|l| l.starts_with("[err]")) {
256            writeln!(out, "{line}")?;
257        }
258    }
259    writeln!(
260        out,
261        "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={}",
262        list_projects(layout)?.len(),
263        check.errors,
264        check.warnings
265    )?;
266    Ok(out)
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::catalog::secret_marker;
273    use crate::config::DEFAULT_PREFIX;
274    use crate::ops::{CreateOpts, create, update};
275    use crate::store::IssueDoc;
276    use std::fs;
277
278    fn layout_with_two_issues() -> (tempfile::TempDir, Layout, String, String) {
279        let dir = tempfile::tempdir().unwrap();
280        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
281        fs::create_dir_all(layout.projects_dir()).unwrap();
282        create(&layout, "sample", "first", CreateOpts::default()).unwrap();
283        create(&layout, "sample", "blocker", CreateOpts::default()).unwrap();
284        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
285        let first = doc.headings[0].id.clone();
286        let blocker = doc.headings[1].id.clone();
287        (dir, layout, first, blocker)
288    }
289
290    #[test]
291    fn claim_moves_an_open_issue_to_started() {
292        let (_dir, layout, first, _blocker) = layout_with_two_issues();
293        let text = claim(&layout, &first, false).unwrap();
294        assert!(text.starts_with(&format!("claimed {first}")), "{text}");
295        assert!(text.contains("State:    STARTED"), "{text}");
296    }
297
298    #[test]
299    fn claim_refuses_a_closed_issue() {
300        let (_dir, layout, first, _blocker) = layout_with_two_issues();
301        update(&layout, &first, Some("DONE"), None, None, None).unwrap();
302        let err = claim(&layout, &first, false).unwrap_err();
303        assert!(err.to_string().contains("cannot claim"), "{err}");
304    }
305
306    #[test]
307    fn ready_json_drops_blocked_issues() {
308        let (_dir, layout, first, blocker) = layout_with_two_issues();
309        update(&layout, &first, None, None, Some(&blocker), None).unwrap();
310        let rows = issues_json(&layout, None, None, true).unwrap();
311        let ids: Vec<&str> = rows
312            .as_array()
313            .unwrap()
314            .iter()
315            .map(|r| r["id"].as_str().unwrap())
316            .collect();
317        assert_eq!(ids, vec![blocker.as_str()], "{rows}");
318    }
319
320    #[test]
321    fn show_json_carries_the_file_range() {
322        let (_dir, layout, first, _blocker) = layout_with_two_issues();
323        let row = show_json(&layout, &first).unwrap();
324        assert_eq!(row["id"].as_str(), Some(first.as_str()));
325        assert_eq!(row["project"].as_str(), Some("sample"));
326        assert!(
327            row["file"].as_str().unwrap().contains("issues.org:"),
328            "{row}"
329        );
330    }
331
332    /// Work that closed naming nothing is a hole in the handoff, but only on a
333    /// tracker that expects one. Reporting it everywhere would flag every
334    /// answered question and closed review, which is how a checklist stops
335    /// being read.
336    #[test]
337    fn closed_work_that_named_no_product_is_reported_only_where_it_is_expected() {
338        let dir = tempfile::tempdir().unwrap();
339        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
340        fs::create_dir_all(layout.projects_dir()).unwrap();
341        create(&layout, "sample", "made something", CreateOpts::default()).unwrap();
342        create(&layout, "sample", "made nothing", CreateOpts::default()).unwrap();
343        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
344        let (first, second) = (doc.headings[0].id.clone(), doc.headings[1].id.clone());
345        crate::ops::deed(&layout, &first, &["deed-file-thing".to_string()], &[]).unwrap();
346        for id in [&first, &second] {
347            update(&layout, id, Some("DONE"), None, None, None).unwrap();
348        }
349
350        let quiet = hygiene(&layout, None).unwrap();
351        assert!(
352            quiet.contains("closed_without_a_product=0"),
353            "off by default: {quiet}"
354        );
355        assert!(!quiet.contains("closed without naming"), "{quiet}");
356
357        fs::write(
358            dir.path().join("vissue.toml"),
359            "[issues]\nexpect_deeds = true\n",
360        )
361        .unwrap();
362        let strict = hygiene(&layout, None).unwrap();
363        assert!(
364            strict.contains("closed_without_a_product=1"),
365            "one of the two named nothing: {strict}"
366        );
367        assert!(
368            strict.contains(&second) && !strict.contains(&format!("made: {first}")),
369            "the one that cited a deed is not a hole: {strict}"
370        );
371    }
372
373    #[test]
374    fn hygiene_flags_a_started_issue_that_is_blocked() {
375        let (_dir, layout, first, blocker) = layout_with_two_issues();
376        update(&layout, &first, Some("STARTED"), None, None, None).unwrap();
377        // Blocking would flip the state, so write the edge without the state move.
378        let path = layout.project_issues_path("sample");
379        let mut doc = IssueDoc::parse_file("sample", &path).unwrap();
380        doc.headings
381            .iter_mut()
382            .find(|h| h.id == first)
383            .unwrap()
384            .properties
385            .insert("BLOCKED_BY".into(), blocker.clone());
386        doc.write().unwrap();
387
388        let text = hygiene(&layout, None).unwrap();
389        assert!(
390            text.contains("never Write or StrReplace issues.org"),
391            "{text}"
392        );
393        assert!(text.contains("STARTED but not ready"), "{text}");
394        assert!(text.contains("started_not_ready=1"), "{text}");
395        assert!(text.contains("[ok] check passed"), "{text}");
396    }
397
398    #[test]
399    fn body_excerpt_returns_the_heading_range() {
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            "documented",
407            CreateOpts {
408                body: Some("Scope: the excerpt path.\nDone-when: it reads back."),
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("Scope: the excerpt path."), "{text}");
416        assert!(text.contains("Done-when: it reads back."), "{text}");
417    }
418
419    #[test]
420    fn the_secret_screen_reads_shapes_not_substrings() {
421        // Suppressed: the shapes a credential is actually written in.
422        for carrier in [
423            // Assembled rather than written out: a literal PEM header in a
424            // source file is exactly what the private-key hook looks for.
425            concat!("-----BEGIN OPENSSH ", "PRIVATE KEY-----"),
426            "aws_secret_access_key = wJalrXUtnFEMI",
427            "Authorization: Bearer abcdefghijklmno",
428            "api_key = 9f8e7d6c5b4a3210ff",
429            "token: ghp_0123456789abcdefghij",
430            "AKIAIOSFODNN7EXAMPLE is the key",
431        ] {
432            assert!(
433                secret_marker(carrier).is_some(),
434                "missed a credential: {carrier:?}"
435            );
436        }
437        // Not suppressed: ordinary prose. A substring screen flags every one
438        // of these -- "making" holds "aki", "task-force" holds "sk-".
439        for prose in [
440            "Scope: read the header block before the first record.",
441            "making the parser reject a bad manifest",
442            "the task-force agreed on the schema",
443            "deployments in Asia are slower",
444            "next-token: reviewed by the release owner",
445            "Deadline: the parser lands before the notes.",
446            "See the design note for the token grammar.",
447        ] {
448            assert_eq!(secret_marker(prose), None, "false positive: {prose:?}");
449        }
450    }
451
452    #[test]
453    fn body_excerpt_suppresses_apparent_secrets() {
454        let dir = tempfile::tempdir().unwrap();
455        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
456        fs::create_dir_all(layout.projects_dir()).unwrap();
457        create(
458            &layout,
459            "sample",
460            "leaky",
461            CreateOpts {
462                body: Some("token: api_key=whatever-it-was"),
463                ..Default::default()
464            },
465        )
466        .unwrap();
467        let doc = IssueDoc::parse_file("sample", &layout.project_issues_path("sample")).unwrap();
468        let text = body_excerpt(&layout, &doc.headings[0].id).unwrap();
469        assert!(text.contains("excerpt suppressed"), "{text}");
470        assert!(!text.contains("whatever-it-was"), "{text}");
471    }
472}