Skip to main content

voro_core/
import.rs

1//! GitHub issue import (DESIGN.md ยง10): one-way capture of issues into tasks.
2//! Voro's store stays the source of truth for priority and state โ€” this module
3//! only maps `gh issue list --json ...` output onto [`NewTask`] and detects
4//! issues already captured, so import is idempotent. The `gh` shell-out is I/O
5//! and lives in the `voro` crate; everything here is pure.
6
7use serde::Deserialize;
8
9use crate::error::{Error, Result};
10use crate::model::{Priority, TaskState};
11use crate::store::NewTask;
12
13/// One issue from `gh issue list --json number,title,body,url`. Those four
14/// fields are all gh documents for this command; anything else it emits is
15/// ignored.
16#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
17pub struct GithubIssue {
18    pub number: i64,
19    pub title: String,
20    #[serde(default)]
21    pub body: String,
22    pub url: String,
23}
24
25impl GithubIssue {
26    /// Parse the JSON array `gh issue list --json ...` prints on stdout.
27    pub fn parse_list(json: &str) -> Result<Vec<GithubIssue>> {
28        serde_json::from_str(json)
29            .map_err(|e| Error::Invalid(format!("invalid `gh issue list` JSON: {e}")))
30    }
31}
32
33/// The task body for an imported issue: the URL and issue number stamped at
34/// the top (both for human reference and as the idempotency marker checked
35/// by [`already_imported`]), followed by the issue body verbatim.
36pub fn issue_task_body(issue: &GithubIssue) -> String {
37    let mut body = format!("Imported from {} (issue #{})\n", issue.url, issue.number);
38    if !issue.body.trim().is_empty() {
39        body.push('\n');
40        body.push_str(&issue.body);
41    }
42    body
43}
44
45/// An issue mapped to a task, always landing in `proposed`: imports are
46/// untriaged like any other machine-generated task, and priority is not
47/// guessed from labels in v1 โ€” triage assigns it. `repo_id` carries the repo
48/// the issues were fetched from, so an imported task dispatches into the
49/// checkout it came from rather than the project default.
50pub fn issue_new_task(project_id: i64, repo_id: Option<i64>, issue: &GithubIssue) -> NewTask {
51    NewTask {
52        project_id,
53        repo_id,
54        title: issue.title.clone(),
55        body: issue_task_body(issue),
56        priority: Priority::P2,
57        state: TaskState::Proposed,
58        agent: None,
59        human: false,
60        deep: false,
61    }
62}
63
64/// Whether `body` (an existing task's body) already carries this issue's
65/// URL โ€” the idempotency check that lets import be run repeatedly without
66/// creating duplicate tasks.
67pub fn already_imported(body: &str, issue: &GithubIssue) -> bool {
68    body.contains(&issue.url)
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    const CANNED: &str = r#"[
76        {
77            "number": 42,
78            "title": "Crash on empty input",
79            "body": "Steps to reproduce:\n1. Run with no args",
80            "url": "https://github.com/acme/widget/issues/42"
81        },
82        {
83            "number": 7,
84            "title": "No body issue",
85            "body": "",
86            "url": "https://github.com/acme/widget/issues/7"
87        }
88    ]"#;
89
90    #[test]
91    fn parses_the_documented_fields() {
92        let issues = GithubIssue::parse_list(CANNED).unwrap();
93        assert_eq!(issues.len(), 2);
94        assert_eq!(issues[0].number, 42);
95        assert_eq!(issues[0].title, "Crash on empty input");
96        assert_eq!(issues[0].url, "https://github.com/acme/widget/issues/42");
97        assert!(issues[0].body.contains("Steps to reproduce"));
98    }
99
100    #[test]
101    fn body_field_defaults_when_absent() {
102        let json = r#"[{"number": 1, "title": "T", "url": "https://x/1"}]"#;
103        let issues = GithubIssue::parse_list(json).unwrap();
104        assert_eq!(issues[0].body, "");
105    }
106
107    #[test]
108    fn rejects_malformed_json() {
109        let err = GithubIssue::parse_list("not json").unwrap_err();
110        assert!(err.to_string().contains("invalid"), "{err}");
111    }
112
113    #[test]
114    fn issue_body_stamps_url_and_number_above_the_issue_text() {
115        let issues = GithubIssue::parse_list(CANNED).unwrap();
116        let body = issue_task_body(&issues[0]);
117        let mut lines = body.lines();
118        assert_eq!(
119            lines.next().unwrap(),
120            "Imported from https://github.com/acme/widget/issues/42 (issue #42)"
121        );
122        assert!(body.contains("Steps to reproduce"));
123    }
124
125    #[test]
126    fn empty_issue_body_still_stamps_the_header_only() {
127        let issues = GithubIssue::parse_list(CANNED).unwrap();
128        let body = issue_task_body(&issues[1]);
129        assert_eq!(
130            body,
131            "Imported from https://github.com/acme/widget/issues/7 (issue #7)\n"
132        );
133    }
134
135    #[test]
136    fn new_task_lands_proposed_with_no_guessed_priority_or_agent() {
137        let issues = GithubIssue::parse_list(CANNED).unwrap();
138        let task = issue_new_task(9, Some(4), &issues[0]);
139        assert_eq!(task.project_id, 9);
140        assert_eq!(task.repo_id, Some(4));
141        assert_eq!(task.title, "Crash on empty input");
142        assert_eq!(task.state, TaskState::Proposed);
143        assert_eq!(task.priority, Priority::P2);
144        assert!(task.agent.is_none());
145        assert!(task.body.contains(&issues[0].url));
146    }
147
148    #[test]
149    fn already_imported_detects_the_stamped_url() {
150        let issues = GithubIssue::parse_list(CANNED).unwrap();
151        let body = issue_task_body(&issues[0]);
152        assert!(already_imported(&body, &issues[0]));
153        assert!(!already_imported(&body, &issues[1]));
154        assert!(!already_imported("unrelated body", &issues[0]));
155    }
156}