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 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
16/// Issue rows as JSON, filtered the same way [`report::list`] filters them.
17pub 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
33/// One issue as JSON, including its file and line range.
34pub 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
40/// Take an issue: move it to STARTED and stamp the claim.
41pub 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
47/// The first lines of an issue's file range, capped and screened for secrets.
48pub 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
57/// One issue's org text, in full, ready to write to a file.
58///
59/// [`body_excerpt`] is a preview and truncates; this does not. It is what a
60/// caller wants when the issue is being handed to someone as the thing to
61/// work from, rather than glanced at.
62pub 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
75/// Issues waiting on this one.
76pub fn waiting_on(layout: &Layout, id: &str) -> Result<String> {
77    report::backlinks(layout, id)
78}
79
80/// The agent and CI checklist: issues claimed but not actionable, claims that
81/// have gone stale, plus the corpus validation summary.
82///
83/// `stale_days` overrides the configured threshold when given.
84pub fn hygiene(layout: &Layout, stale_days: Option<i64>) -> Result<String> {
85    let mut out = String::new();
86    writeln!(out, "=== vissue hygiene ===")?;
87
88    // Compare ids, not rendered rows: `id_length` is configurable, so one id
89    // can be a prefix of another and a row match would pair the wrong issues.
90    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        // Blocking would flip the state, so write the edge without the state move.
233        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        // Suppressed: the shapes a credential is actually written in.
273        for carrier in [
274            // Assembled rather than written out: a literal PEM header in a
275            // source file is exactly what the private-key hook looks for.
276            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        // Not suppressed: ordinary prose. A substring screen flags every one
289        // of these -- "making" holds "aki", "task-force" holds "sk-".
290        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}