1use futures::Stream;
2use serde::Deserialize;
3use serde_json::Value;
4
5use crate::api::custom_fields::IssueCustomField;
6use crate::api::{Client, Projection};
7use crate::error::{Result, YtError};
8
9#[derive(Clone, Debug, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct ProjectRef {
12 pub short_name: String,
13 pub name: Option<String>,
14}
15
16#[derive(Clone, Debug, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct UserRef {
19 pub login: String,
20 pub full_name: Option<String>,
21}
22
23#[derive(Clone, Debug, Deserialize)]
24pub struct TagRef {
25 pub name: String,
26}
27
28#[derive(Clone, Debug, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct IssueDetail {
31 pub id_readable: String,
32 pub summary: Option<String>,
33 pub description: Option<String>,
34 pub project: ProjectRef,
35 pub reporter: Option<UserRef>,
36 pub created: i64,
37 pub updated: i64,
38 pub resolved: Option<i64>,
39 #[serde(default)]
40 pub tags: Vec<TagRef>,
41 #[serde(default)]
42 pub custom_fields: Vec<IssueCustomField>,
43}
44
45impl Projection for IssueDetail {
46 const FIELDS: &'static str = "idReadable,summary,description,project(shortName,name),reporter(login,fullName),created,updated,resolved,tags(name),customFields(name,$type,projectCustomField(field(fieldType(id))),value(name,login,fullName,isResolved,minutes,presentation,text,$type))";
47}
48
49pub async fn get(client: &Client, id: &str) -> Result<IssueDetail> {
50 client.get(&format!("issues/{id}"), &[]).await
51}
52
53#[derive(Clone, Debug, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct IssueRow {
56 pub id_readable: String,
57 pub summary: Option<String>,
58 pub project: ProjectRef,
59 pub created: i64,
60 pub updated: i64,
61 pub resolved: Option<i64>,
62 #[serde(default)]
63 pub custom_fields: Vec<IssueCustomField>,
64}
65
66impl Projection for IssueRow {
67 const FIELDS: &'static str = "idReadable,summary,project(shortName,name),created,updated,resolved,customFields(name,$type,projectCustomField(field(fieldType(id))),value(name,login,fullName,isResolved,minutes,presentation,text,$type))";
68}
69
70impl IssueRow {
71 pub fn state(&self) -> Option<String> {
73 crate::api::custom_fields::state_of(&self.custom_fields)
74 }
75}
76
77pub fn search<'a>(
78 client: &'a Client,
79 query: &str,
80 page_size: usize,
81) -> impl Stream<Item = Result<IssueRow>> + Send + 'a {
82 let mut parameters = Vec::new();
83 if !query.is_empty() {
84 parameters.push(("query".to_string(), query.to_string()));
85 }
86 client.paginate("issues".to_string(), parameters, page_size)
87}
88
89pub fn build_query(
91 user_query: Option<&str>,
92 project: Option<&str>,
93 stable_sort: bool,
94) -> Result<String> {
95 let mut parts = Vec::new();
96 if let Some(project) = project {
97 if project.contains('}') {
98 return Err(YtError::Usage {
99 message: format!("'{project}' cannot be used in a query"),
100 }
101 .into());
102 }
103 parts.push(format!("project: {{{project}}}"));
104 }
105 if let Some(query) = user_query.map(str::trim).filter(|query| !query.is_empty()) {
106 if parts.is_empty() {
107 parts.push(query.to_string());
108 } else {
109 let lower = query.to_ascii_lowercase();
112 let sort = ["sort by", "order by"]
113 .iter()
114 .filter_map(|keyword| lower.find(keyword))
115 .min()
116 .unwrap_or(query.len());
117 let (filter, order) = query.split_at(sort);
118 if !filter.trim().is_empty() {
119 parts.push(format!("and ({})", filter.trim()));
120 }
121 if !order.is_empty() {
122 parts.push(order.to_string());
123 }
124 }
125 }
126 let mut query = parts.join(" ");
127 let lower = query.to_ascii_lowercase();
128 if stable_sort && !lower.contains("sort by") && !lower.contains("order by") {
129 if !query.is_empty() {
130 query.push(' ');
131 }
132 query.push_str("sort by: created asc");
133 }
134 Ok(query)
135}
136
137#[derive(Deserialize)]
138#[serde(rename_all = "camelCase")]
139struct Created {
140 id_readable: String,
141}
142
143impl Projection for Created {
144 const FIELDS: &'static str = "idReadable";
145}
146
147pub async fn create(client: &Client, body: &Value) -> Result<String> {
149 let created: Created = client.post("issues", body).await?;
150 Ok(created.id_readable)
151}
152
153pub async fn update(client: &Client, id: &str, body: &Value) -> Result<()> {
154 client.post_unit(&format!("issues/{id}"), body).await
155}
156
157pub async fn delete(client: &Client, id: &str) -> Result<()> {
158 client.delete(&format!("issues/{id}")).await
159}
160
161#[derive(Deserialize)]
162#[serde(rename_all = "camelCase")]
163struct InProject {
164 project: ProjectRef,
165}
166
167impl Projection for InProject {
168 const FIELDS: &'static str = "project(shortName,name)";
169}
170
171pub async fn project(client: &Client, id: &str) -> Result<String> {
173 let found: InProject = client.get(&format!("issues/{id}"), &[]).await?;
174 Ok(found.project.short_name)
175}
176
177pub fn project_of(id: &str) -> &str {
179 id.rsplit_once('-').map_or(id, |(project, _)| project)
180}
181
182pub fn by_project(ids: &[String]) -> Vec<(String, Vec<String>)> {
184 let mut groups: Vec<(String, Vec<String>)> = Vec::new();
185 for id in ids {
186 let project = project_of(id);
187 match groups.iter_mut().find(|(name, _)| name == project) {
188 Some((_, members)) => members.push(id.clone()),
189 None => groups.push((project.to_string(), vec![id.clone()])),
190 }
191 }
192 groups
193}
194
195#[cfg(test)]
196mod tests {
197 use serde_json::json;
198 use wiremock::matchers::{body_json, method, path};
199 use wiremock::{Mock, MockServer, ResponseTemplate};
200
201 use super::*;
202 use crate::api::projection::check_selector;
203 use crate::api::test_client;
204
205 #[test]
206 fn selectors_are_valid_and_include_the_custom_field_selector() {
207 assert_eq!(check_selector(IssueDetail::FIELDS), Ok(()));
208 assert!(IssueDetail::FIELDS.contains(crate::api::custom_fields::FIELDS));
209 }
210
211 #[test]
212 fn the_show_fixture_decodes() {
213 let text = include_str!("../../tests/fixtures/issue-show.json");
214 let issue: IssueDetail = serde_json::from_str(text).unwrap();
215 assert_eq!(issue.id_readable, "WEB-42");
216 assert_eq!(issue.custom_fields.len(), 4);
217 }
218
219 #[test]
220 fn queries_are_built_from_flags_and_words() {
221 assert_eq!(build_query(None, None, false).unwrap(), "");
222 assert_eq!(
223 build_query(Some(" for: me "), None, false).unwrap(),
224 "for: me"
225 );
226 assert_eq!(
227 build_query(Some("#Unresolved"), Some("Web Site"), false).unwrap(),
228 "project: {Web Site} and (#Unresolved)"
229 );
230 assert_eq!(
231 build_query(None, Some("WEB"), true).unwrap(),
232 "project: {WEB} sort by: created asc"
233 );
234 assert_eq!(
235 build_query(Some("sort by: updated desc"), None, true).unwrap(),
236 "sort by: updated desc"
237 );
238 assert!(build_query(None, Some("A}B"), false).is_err());
239 }
240
241 #[test]
242 fn the_list_fixture_decodes_and_finds_the_state() {
243 let rows: Vec<IssueRow> =
244 serde_json::from_str(include_str!("../../tests/fixtures/issue-list.json")).unwrap();
245 assert_eq!(rows.len(), 2);
246 assert_eq!(rows[0].state().as_deref(), Some("Open"));
247 assert_eq!(rows[1].state(), None);
248 assert_eq!(check_selector(IssueRow::FIELDS), Ok(()));
249 }
250
251 #[test]
252 fn the_project_scopes_the_whole_query() {
253 assert_eq!(
254 build_query(Some("for: me or #Critical"), Some("WEB"), false).unwrap(),
255 "project: {WEB} and (for: me or #Critical)"
256 );
257 assert_eq!(
258 build_query(Some("#Unresolved Sort By: updated desc"), Some("WEB"), true).unwrap(),
259 "project: {WEB} and (#Unresolved) Sort By: updated desc"
260 );
261 assert_eq!(
262 build_query(Some("sort by: votes"), Some("WEB"), false).unwrap(),
263 "project: {WEB} sort by: votes"
264 );
265 assert_eq!(
266 build_query(Some("for: me or #Critical"), None, false).unwrap(),
267 "for: me or #Critical"
268 );
269 }
270
271 #[tokio::test]
272 async fn create_update_and_delete_use_the_issue_endpoints() {
273 let server = MockServer::start().await;
274 Mock::given(method("POST"))
275 .and(path("/api/issues"))
276 .and(body_json(json!({"summary": "x"})))
277 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"idReadable": "WEB-43"})))
278 .expect(1)
279 .mount(&server)
280 .await;
281 Mock::given(method("POST"))
282 .and(path("/api/issues/WEB-43"))
283 .and(body_json(json!({"summary": "y"})))
284 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
285 .expect(1)
286 .mount(&server)
287 .await;
288 Mock::given(method("DELETE"))
289 .and(path("/api/issues/WEB-43"))
290 .respond_with(ResponseTemplate::new(200))
291 .expect(1)
292 .mount(&server)
293 .await;
294 let client = test_client(&server.uri());
295 let id = create(&client, &json!({"summary": "x"})).await.unwrap();
296 assert_eq!(id, "WEB-43");
297 update(&client, &id, &json!({"summary": "y"}))
298 .await
299 .unwrap();
300 delete(&client, &id).await.unwrap();
301 }
302
303 #[test]
304 fn issues_group_by_project_in_order() {
305 let ids: Vec<String> = ["WEB-1", "OPS-2", "WEB-3", "MY_APP-4"]
306 .map(String::from)
307 .to_vec();
308 assert_eq!(project_of("MY_APP-4"), "MY_APP");
309 assert_eq!(
310 by_project(&ids),
311 vec![
312 (
313 "WEB".to_string(),
314 vec!["WEB-1".to_string(), "WEB-3".to_string()]
315 ),
316 ("OPS".to_string(), vec!["OPS-2".to_string()]),
317 ("MY_APP".to_string(), vec!["MY_APP-4".to_string()]),
318 ]
319 );
320 }
321}