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, 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
64pub 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}