Skip to main content

yt/api/
issues.rs

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    /// The value of the issue's state field: the one whose values know whether they are resolved.
72    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
89/// Joins convenience clauses and the user's query by juxtaposition; `stable_sort` adds an order that paging cannot skip or repeat under.
90pub 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            // `or` binds looser than `and`, so the flag clauses must scope the whole query. A group
110            // needs an explicit `and` (juxtaposition only joins plain terms), and `sort by` stays outside it.
111            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
147/// Creates an issue from a REST body and returns its readable ID.
148pub 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
171/// The project an issue is in now; a moved issue keeps answering to its old ID.
172pub 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
177/// The project short name of a readable ID (`WEB-42` → `WEB`).
178pub fn project_of(id: &str) -> &str {
179    id.rsplit_once('-').map_or(id, |(project, _)| project)
180}
181
182/// Readable IDs grouped by project, in order of first appearance.
183pub 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}