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.
48pub fn issue_new_task(project_id: i64, issue: &GithubIssue) -> NewTask {
49    NewTask {
50        project_id,
51        title: issue.title.clone(),
52        body: issue_task_body(issue),
53        priority: Priority::P2,
54        state: TaskState::Proposed,
55        agent: None,
56        human: false,
57    }
58}
59
60/// Whether `body` (an existing task's body) already carries this issue's
61/// URL โ€” the idempotency check that lets import be run repeatedly without
62/// creating duplicate tasks.
63pub fn already_imported(body: &str, issue: &GithubIssue) -> bool {
64    body.contains(&issue.url)
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    const CANNED: &str = r#"[
72        {
73            "number": 42,
74            "title": "Crash on empty input",
75            "body": "Steps to reproduce:\n1. Run with no args",
76            "url": "https://github.com/acme/widget/issues/42"
77        },
78        {
79            "number": 7,
80            "title": "No body issue",
81            "body": "",
82            "url": "https://github.com/acme/widget/issues/7"
83        }
84    ]"#;
85
86    #[test]
87    fn parses_the_documented_fields() {
88        let issues = GithubIssue::parse_list(CANNED).unwrap();
89        assert_eq!(issues.len(), 2);
90        assert_eq!(issues[0].number, 42);
91        assert_eq!(issues[0].title, "Crash on empty input");
92        assert_eq!(issues[0].url, "https://github.com/acme/widget/issues/42");
93        assert!(issues[0].body.contains("Steps to reproduce"));
94    }
95
96    #[test]
97    fn body_field_defaults_when_absent() {
98        let json = r#"[{"number": 1, "title": "T", "url": "https://x/1"}]"#;
99        let issues = GithubIssue::parse_list(json).unwrap();
100        assert_eq!(issues[0].body, "");
101    }
102
103    #[test]
104    fn rejects_malformed_json() {
105        let err = GithubIssue::parse_list("not json").unwrap_err();
106        assert!(err.to_string().contains("invalid"), "{err}");
107    }
108
109    #[test]
110    fn issue_body_stamps_url_and_number_above_the_issue_text() {
111        let issues = GithubIssue::parse_list(CANNED).unwrap();
112        let body = issue_task_body(&issues[0]);
113        let mut lines = body.lines();
114        assert_eq!(
115            lines.next().unwrap(),
116            "Imported from https://github.com/acme/widget/issues/42 (issue #42)"
117        );
118        assert!(body.contains("Steps to reproduce"));
119    }
120
121    #[test]
122    fn empty_issue_body_still_stamps_the_header_only() {
123        let issues = GithubIssue::parse_list(CANNED).unwrap();
124        let body = issue_task_body(&issues[1]);
125        assert_eq!(
126            body,
127            "Imported from https://github.com/acme/widget/issues/7 (issue #7)\n"
128        );
129    }
130
131    #[test]
132    fn new_task_lands_proposed_with_no_guessed_priority_or_agent() {
133        let issues = GithubIssue::parse_list(CANNED).unwrap();
134        let task = issue_new_task(9, &issues[0]);
135        assert_eq!(task.project_id, 9);
136        assert_eq!(task.title, "Crash on empty input");
137        assert_eq!(task.state, TaskState::Proposed);
138        assert_eq!(task.priority, Priority::P2);
139        assert!(task.agent.is_none());
140        assert!(task.body.contains(&issues[0].url));
141    }
142
143    #[test]
144    fn already_imported_detects_the_stamped_url() {
145        let issues = GithubIssue::parse_list(CANNED).unwrap();
146        let body = issue_task_body(&issues[0]);
147        assert!(already_imported(&body, &issues[0]));
148        assert!(!already_imported(&body, &issues[1]));
149        assert!(!already_imported("unrelated body", &issues[0]));
150    }
151}