1use serde::Deserialize;
8
9use crate::error::{Error, Result};
10use crate::model::{Priority, TaskState};
11use crate::store::NewTask;
12
13#[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 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
33pub 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
45pub 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
60pub 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}