Skip to main content

yt/api/
projects.rs

1use serde::Deserialize;
2
3use crate::api::issues::UserRef;
4use crate::api::{Client, Projection};
5use crate::error::Result;
6use crate::fields::schema::check_short_name;
7use crate::fields::{choices, closest, same, usage};
8
9#[derive(Clone, Debug, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct Project {
12    pub short_name: String,
13    #[serde(default)]
14    pub name: String,
15    pub leader: Option<UserRef>,
16    pub description: Option<String>,
17    #[serde(default)]
18    pub archived: bool,
19}
20
21impl Projection for Project {
22    const FIELDS: &'static str = "shortName,name,leader(login,fullName),description,archived";
23}
24
25/// Every project the user can see, archived ones included.
26pub async fn list(client: &Client) -> Result<Vec<Project>> {
27    client.get_all("admin/projects").await
28}
29
30/// Short names of the projects the user can see, without archived ones.
31pub async fn short_names(client: &Client) -> Result<Vec<String>> {
32    Ok(list(client)
33        .await?
34        .into_iter()
35        .filter(|project| !project.archived)
36        .map(|project| project.short_name)
37        .collect())
38}
39
40/// The project with this short name in any case; otherwise an error naming the closest one.
41pub fn find<'a>(projects: &'a [Project], name: &str) -> Result<&'a Project> {
42    if let Some(project) = projects
43        .iter()
44        .find(|project| same(&project.short_name, name))
45    {
46        return Ok(project);
47    }
48    let names: Vec<String> = projects
49        .iter()
50        .map(|project| project.short_name.clone())
51        .collect();
52    let mut message = format!("there is no project '{name}'");
53    if let Some(close) = closest(name, names.iter().map(String::as_str)) {
54        message.push_str(&format!(" (did you mean '{close}'?)"));
55    } else if !names.is_empty() {
56        message.push_str(&format!("; projects: {}", choices(&names)));
57    }
58    Err(usage(message))
59}
60
61#[derive(Clone, Debug, Deserialize)]
62pub struct WorkItemType {
63    pub id: String,
64    pub name: String,
65}
66
67impl Projection for WorkItemType {
68    const FIELDS: &'static str = "id,name";
69}
70
71#[derive(Clone, Debug, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct TimeTracking {
74    #[serde(default)]
75    pub enabled: bool,
76    #[serde(default)]
77    pub work_item_types: Vec<WorkItemType>,
78}
79
80impl Projection for TimeTracking {
81    const FIELDS: &'static str = "enabled,workItemTypes(id,name)";
82}
83
84pub async fn time_tracking(client: &Client, project: &str) -> Result<TimeTracking> {
85    check_short_name(project)?;
86    client
87        .get(
88            &format!("admin/projects/{project}/timeTrackingSettings"),
89            &[],
90        )
91        .await
92}
93
94#[cfg(test)]
95mod tests {
96    use serde_json::json;
97    use wiremock::matchers::{method, path};
98    use wiremock::{Mock, MockServer, ResponseTemplate};
99
100    use super::*;
101    use crate::api::test_client;
102
103    #[tokio::test]
104    async fn archived_projects_are_left_out() {
105        let server = MockServer::start().await;
106        Mock::given(method("GET"))
107            .and(path("/api/admin/projects"))
108            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
109                {"shortName": "WEB", "archived": false},
110                {"shortName": "OLD", "archived": true}
111            ])))
112            .mount(&server)
113            .await;
114        assert_eq!(
115            short_names(&test_client(&server.uri())).await.unwrap(),
116            ["WEB"]
117        );
118    }
119
120    fn project(short: &str) -> Project {
121        Project {
122            short_name: short.into(),
123            name: format!("{short} project"),
124            leader: None,
125            description: None,
126            archived: false,
127        }
128    }
129
130    #[test]
131    fn projects_are_found_in_any_case_with_a_suggestion_otherwise() {
132        let projects = [project("TEST"), project("APP")];
133        assert_eq!(find(&projects, "test").unwrap().short_name, "TEST");
134        let error = find(&projects, "TES").unwrap_err().to_string();
135        assert!(error.contains("did you mean 'TEST'"), "{error}");
136        let error = find(&projects, "zzz").unwrap_err().to_string();
137        assert!(error.contains("projects: TEST, APP"), "{error}");
138    }
139
140    #[tokio::test]
141    async fn time_tracking_settings_are_read_by_short_name() {
142        let server = MockServer::start().await;
143        Mock::given(method("GET"))
144            .and(path("/api/admin/projects/TEST/timeTrackingSettings"))
145            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
146                "enabled": true,
147                "workItemTypes": [{"id": "85-0", "name": "Development"}]
148            })))
149            .mount(&server)
150            .await;
151        let settings = time_tracking(&test_client(&server.uri()), "TEST")
152            .await
153            .unwrap();
154        assert!(settings.enabled);
155        assert_eq!(settings.work_item_types[0].id, "85-0");
156        assert!(
157            time_tracking(&test_client(&server.uri()), "../x")
158                .await
159                .is_err()
160        );
161    }
162}