gr/gitlab/
project.rs

1use crate::api_traits::{ApiOperation, ProjectMember, RemoteProject, RemoteTag};
2use crate::cli::browse::BrowseOptions;
3use crate::cmds::project::{Member, Project, ProjectListBodyArgs, Tag};
4use crate::error::GRError;
5use crate::gitlab::encode_path;
6use crate::io::{CmdInfo, HttpResponse, HttpRunner};
7use crate::remote::query;
8use crate::remote::URLQueryParamBuilder;
9use crate::Result;
10
11use super::Gitlab;
12
13impl<R: HttpRunner<Response = HttpResponse>> RemoteProject for Gitlab<R> {
14    fn get_project_data(&self, id: Option<i64>, path: Option<&str>) -> Result<CmdInfo> {
15        let url = match (id, path) {
16            (Some(id), None) => format!("{}/{}", self.base_project_url, id),
17            (None, Some(path)) => {
18                format!("{}/{}", self.base_project_url, encode_path(path))
19            }
20            (None, None) => self.rest_api_basepath().to_string(),
21            (Some(_), Some(_)) => {
22                return Err(GRError::ApplicationError(
23                    "Invalid arguments, can only get project data by id or by owner/repo path"
24                        .to_string(),
25                )
26                .into());
27            }
28        };
29        let project = query::get::<_, (), _>(
30            &self.runner,
31            &url,
32            None,
33            self.headers(),
34            ApiOperation::Project,
35            |value| GitlabProjectFields::from(value).into(),
36        )?;
37        Ok(CmdInfo::Project(project))
38    }
39
40    fn get_project_members(&self) -> Result<CmdInfo> {
41        let url = format!("{}/members/all", self.rest_api_basepath());
42        let members = query::paged(
43            &self.runner,
44            &url,
45            None,
46            self.headers(),
47            None,
48            ApiOperation::Project,
49            |value| GitlabMemberFields::from(value).into(),
50        )?;
51        Ok(CmdInfo::Members(members))
52    }
53
54    fn get_url(&self, option: BrowseOptions) -> String {
55        let base_url = format!("https://{}/{}", self.domain, self.path);
56        match option {
57            BrowseOptions::Repo => base_url,
58            BrowseOptions::MergeRequests => format!("{base_url}/merge_requests"),
59            BrowseOptions::MergeRequestId(id) => format!("{base_url}/-/merge_requests/{id}"),
60            BrowseOptions::Pipelines => format!("{base_url}/pipelines"),
61            BrowseOptions::PipelineId(id) => format!("{base_url}/-/pipelines/{id}"),
62            BrowseOptions::Releases => format!("{base_url}/-/releases"),
63            // Manual is only one URL and it's the user guide. Handled in the
64            // browser command.
65            BrowseOptions::Manual => unreachable!(),
66        }
67    }
68
69    fn list(&self, args: ProjectListBodyArgs) -> Result<Vec<Project>> {
70        let url = self.list_project_url(&args, false);
71        let projects = query::paged(
72            &self.runner,
73            &url,
74            args.from_to_page,
75            self.headers(),
76            None,
77            ApiOperation::Project,
78            |value| GitlabProjectFields::from(value).into(),
79        )?;
80        Ok(projects)
81    }
82
83    fn num_pages(&self, args: ProjectListBodyArgs) -> Result<Option<u32>> {
84        let url = self.list_project_url(&args, true);
85        query::num_pages(&self.runner, &url, self.headers(), ApiOperation::Project)
86    }
87
88    fn num_resources(
89        &self,
90        args: ProjectListBodyArgs,
91    ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
92        let url = self.list_project_url(&args, true);
93        query::num_resources(&self.runner, &url, self.headers(), ApiOperation::Project)
94    }
95}
96
97impl<R: HttpRunner<Response = HttpResponse>> RemoteTag for Gitlab<R> {
98    // https://docs.gitlab.com/ee/api/tags.html
99    fn list(&self, args: ProjectListBodyArgs) -> Result<Vec<Tag>> {
100        let url = format!("{}/repository/tags", self.projects_base_url);
101        let tags = query::paged(
102            &self.runner,
103            &url,
104            args.from_to_page,
105            self.headers(),
106            None,
107            ApiOperation::RepositoryTag,
108            |value| GitlabProjectTagFields::from(value).into(),
109        )?;
110        Ok(tags)
111    }
112    // NOTE: For num_resources and num_pages, the ApiOperation::Project from the
113    // RemoteProject trait is being used, but those operations involve a single
114    // HEAD request, which is not cached and does not require pagination. So,
115    // technically speaking is not required. Might be a TODO to change/add an
116    // ApiOperation to reflect this.
117}
118
119impl<R: HttpRunner<Response = HttpResponse>> ProjectMember for Gitlab<R> {
120    fn list(&self, args: ProjectListBodyArgs) -> Result<Vec<Member>> {
121        let url = format!("{}/members/all", self.rest_api_basepath());
122        let members = query::paged(
123            &self.runner,
124            &url,
125            args.from_to_page,
126            self.headers(),
127            None,
128            ApiOperation::Project,
129            |value| GitlabMemberFields::from(value).into(),
130        )?;
131        Ok(members)
132    }
133}
134
135impl<R> Gitlab<R> {
136    fn list_project_url(&self, args: &ProjectListBodyArgs, num_pages: bool) -> String {
137        let mut url = if args.tags {
138            URLQueryParamBuilder::new(&format!("{}/repository/tags", self.projects_base_url))
139        } else if args.members {
140            URLQueryParamBuilder::new(&format!("{}/members/all", self.projects_base_url))
141        } else {
142            let user = args.user.as_ref().unwrap().clone();
143            if args.stars {
144                URLQueryParamBuilder::new(&format!(
145                    "{}/{}/starred_projects",
146                    self.base_users_url, user.id
147                ))
148            } else {
149                URLQueryParamBuilder::new(&format!("{}/{}/projects", self.base_users_url, user.id))
150            }
151        };
152        if num_pages {
153            return url.add_param("page", "1").build();
154        }
155        url.build()
156    }
157}
158
159pub struct GitlabProjectTagFields {
160    tag: Tag,
161}
162
163impl From<&serde_json::Value> for GitlabProjectTagFields {
164    fn from(data: &serde_json::Value) -> Self {
165        GitlabProjectTagFields {
166            tag: Tag::builder()
167                .name(data["name"].as_str().unwrap().to_string())
168                .sha(data["commit"]["id"].as_str().unwrap().to_string())
169                .created_at(data["created_at"].as_str().unwrap().to_string())
170                .build()
171                .unwrap(),
172        }
173    }
174}
175
176impl From<GitlabProjectTagFields> for Tag {
177    fn from(fields: GitlabProjectTagFields) -> Self {
178        fields.tag
179    }
180}
181
182pub struct GitlabProjectFields {
183    project: Project,
184}
185
186impl From<&serde_json::Value> for GitlabProjectFields {
187    fn from(data: &serde_json::Value) -> Self {
188        GitlabProjectFields {
189            project: Project::builder()
190                .id(data["id"].as_i64().unwrap())
191                .default_branch(data["default_branch"].as_str().unwrap().to_string())
192                .html_url(data["web_url"].as_str().unwrap().to_string())
193                .created_at(data["created_at"].as_str().unwrap().to_string())
194                .description(data["description"].as_str().unwrap_or_default().to_string())
195                // NOTE: Project language key is not present in the Gitlab API response.
196                .build()
197                .unwrap(),
198        }
199    }
200}
201
202impl From<GitlabProjectFields> for Project {
203    fn from(fields: GitlabProjectFields) -> Self {
204        fields.project
205    }
206}
207
208pub struct GitlabMemberFields {
209    member: Member,
210}
211
212impl From<&serde_json::Value> for GitlabMemberFields {
213    fn from(data: &serde_json::Value) -> Self {
214        GitlabMemberFields {
215            member: Member::builder()
216                .id(data["id"].as_i64().unwrap())
217                .name(data["name"].as_str().unwrap().to_string())
218                .username(data["username"].as_str().unwrap().to_string())
219                .created_at(data["created_at"].as_str().unwrap().to_string())
220                .build()
221                .unwrap(),
222        }
223    }
224}
225
226impl From<GitlabMemberFields> for Member {
227    fn from(fields: GitlabMemberFields) -> Self {
228        fields.member
229    }
230}
231
232#[cfg(test)]
233mod test {
234
235    use crate::api_traits::ApiOperation;
236    use crate::cmds::project::ProjectListBodyArgs;
237    use crate::http::Headers;
238    use crate::setup_client;
239    use crate::test::utils::{
240        default_gitlab, get_contract, BasePath, ClientType, ContractType, Domain, ResponseContracts,
241    };
242
243    use crate::io::CmdInfo;
244
245    use super::*;
246
247    #[test]
248    fn test_get_project_data_no_id() {
249        let contracts =
250            ResponseContracts::new(ContractType::Gitlab).add_contract(200, "project.json", None);
251        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn RemoteProject);
252        gitlab.get_project_data(None, None).unwrap();
253        assert_eq!(
254            "https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi",
255            client.url().to_string(),
256        );
257        assert_eq!("1234", client.headers().get("PRIVATE-TOKEN").unwrap());
258        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
259    }
260
261    #[test]
262    fn test_get_project_data_with_given_id() {
263        let contracts =
264            ResponseContracts::new(ContractType::Gitlab).add_contract(200, "project.json", None);
265        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn RemoteProject);
266        gitlab.get_project_data(Some(54345), None).unwrap();
267        assert_eq!(
268            "https://gitlab.com/api/v4/projects/54345",
269            client.url().to_string(),
270        );
271        assert_eq!("1234", client.headers().get("PRIVATE-TOKEN").unwrap());
272        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
273    }
274
275    #[test]
276    fn test_get_project_data_given_owner_repo_path() {
277        // current repository path where user is cd'd into.
278        let path = "gitlab-org/gitlab-foss";
279        let client_type =
280            ClientType::Gitlab(Domain("gitlab.com".to_string()), BasePath(path.to_string()));
281        let contracts =
282            ResponseContracts::new(ContractType::Gitlab).add_contract(200, "project.json", None);
283        let (client, gitlab) = setup_client!(contracts, client_type, dyn RemoteProject);
284        // User requests information on a different repository.
285        let result = gitlab.get_project_data(None, Some("jordilin/gitlapi"));
286        assert_eq!(
287            "https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi",
288            client.url().to_string(),
289        );
290        match result {
291            Ok(CmdInfo::Project(project)) => {
292                assert_eq!(44438708, project.id);
293            }
294            _ => panic!("Expected project"),
295        }
296    }
297
298    #[test]
299    fn test_get_project_data_error_if_both_id_and_path_given() {
300        let contracts = ResponseContracts::new(ContractType::Gitlab);
301        let (_, gitlab) = setup_client!(contracts, default_gitlab(), dyn RemoteProject);
302        let result = gitlab.get_project_data(Some(54345), Some("jordilin/gitlapi"));
303        match result {
304            Err(err) => match err.downcast_ref::<GRError>() {
305                Some(GRError::ApplicationError(msg)) => {
306                    assert_eq!(
307                        "Invalid arguments, can only get project data by id or by owner/repo path",
308                        msg
309                    );
310                }
311                _ => panic!("Expected ApplicationError"),
312            },
313            _ => panic!("Expected ApplicationError"),
314        }
315    }
316
317    #[test]
318    fn test_get_project_members() {
319        let contracts = ResponseContracts::new(ContractType::Gitlab).add_contract(
320            200,
321            "project_members.json",
322            None,
323        );
324        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn RemoteProject);
325        let CmdInfo::Members(members) = gitlab.get_project_members().unwrap() else {
326            panic!("Expected members");
327        };
328        assert_eq!(2, members.len());
329        assert_eq!("test_user_0", members[0].username);
330        assert_eq!("test_user_1", members[1].username);
331        assert_eq!("1234", client.headers().get("PRIVATE-TOKEN").unwrap());
332        assert_eq!(
333            "https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi/members/all",
334            *client.url(),
335        );
336        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
337    }
338
339    #[test]
340    fn test_list_user_projects() {
341        let contracts = ResponseContracts::new(ContractType::Gitlab).add_body(
342            200,
343            Some(format!(
344                "[{}]",
345                get_contract(ContractType::Gitlab, "project.json")
346            )),
347            None,
348        );
349        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn RemoteProject);
350        let body_args = ProjectListBodyArgs::builder()
351            .from_to_page(None)
352            .user(Some(
353                Member::builder()
354                    .id(1)
355                    .name("jordi".to_string())
356                    .username("jordilin".to_string())
357                    .build()
358                    .unwrap(),
359            ))
360            .build()
361            .unwrap();
362        gitlab.list(body_args).unwrap();
363        assert_eq!(
364            "https://gitlab.com/api/v4/users/1/projects",
365            client.url().to_string(),
366        );
367        assert_eq!("1234", client.headers().get("PRIVATE-TOKEN").unwrap());
368        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
369    }
370
371    #[test]
372    fn test_get_my_starred_projects() {
373        let contracts =
374            ResponseContracts::new(ContractType::Gitlab).add_contract(200, "stars.json", None);
375        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn RemoteProject);
376        let body_args = ProjectListBodyArgs::builder()
377            .from_to_page(None)
378            .user(Some(
379                Member::builder()
380                    .id(1)
381                    .name("jordi".to_string())
382                    .username("jordilin".to_string())
383                    .build()
384                    .unwrap(),
385            ))
386            .stars(true)
387            .build()
388            .unwrap();
389        gitlab.list(body_args).unwrap();
390        assert_eq!(
391            "https://gitlab.com/api/v4/users/1/starred_projects",
392            client.url().to_string(),
393        );
394        assert_eq!("1234", client.headers().get("PRIVATE-TOKEN").unwrap());
395        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
396    }
397
398    #[test]
399    fn test_get_num_pages_url_for_user_projects() {
400        let link_headers = "<https://gitlab.com/api/v4/users/1/projects?page=2&per_page=20>; rel=\"next\", <https://gitlab.com/api/v4/users/1/projects?page=2&per_page=20>; rel=\"last\"";
401        let mut headers = Headers::new();
402        headers.set("link", link_headers);
403        let contracts = ResponseContracts::new(ContractType::Gitlab).add_contract(
404            200,
405            "project.json",
406            Some(headers),
407        );
408        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn RemoteProject);
409        let body_args = ProjectListBodyArgs::builder()
410            .from_to_page(None)
411            .user(Some(
412                Member::builder()
413                    .id(1)
414                    .name("jordi".to_string())
415                    .username("jordilin".to_string())
416                    .build()
417                    .unwrap(),
418            ))
419            .build()
420            .unwrap();
421        gitlab.num_pages(body_args).unwrap();
422        assert_eq!(
423            "https://gitlab.com/api/v4/users/1/projects?page=1",
424            client.url().to_string(),
425        );
426        assert_eq!(
427            ApiOperation::Project,
428            *client.api_operation.borrow().as_ref().unwrap()
429        );
430    }
431
432    #[test]
433    fn test_get_project_num_pages_url_for_starred() {
434        let link_headers = "<https://gitlab.com/api/v4/users/1/starred_projects?page=2&per_page=20>; rel=\"next\", <https://gitlab.com/api/v4/users/1/starred_projects?page=2&per_page=20>; rel=\"last\"";
435        let mut headers = Headers::new();
436        headers.set("link", link_headers);
437        let contracts = ResponseContracts::new(ContractType::Gitlab).add_contract(
438            200,
439            "project.json",
440            Some(headers),
441        );
442        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn RemoteProject);
443        let body_args = ProjectListBodyArgs::builder()
444            .from_to_page(None)
445            .user(Some(
446                Member::builder()
447                    .id(1)
448                    .name("jordi".to_string())
449                    .username("jordilin".to_string())
450                    .build()
451                    .unwrap(),
452            ))
453            .stars(true)
454            .build()
455            .unwrap();
456        gitlab.num_pages(body_args).unwrap();
457        assert_eq!(
458            "https://gitlab.com/api/v4/users/1/starred_projects?page=1",
459            client.url().to_string(),
460        );
461        assert_eq!(
462            ApiOperation::Project,
463            *client.api_operation.borrow().as_ref().unwrap()
464        );
465    }
466
467    #[test]
468    fn test_get_url_pipeline_id() {
469        let contracts = ResponseContracts::new(ContractType::Gitlab);
470        let (_, github) = setup_client!(contracts, default_gitlab(), dyn RemoteProject);
471        let url = github.get_url(BrowseOptions::PipelineId(9527070386));
472        assert_eq!(
473            "https://gitlab.com/jordilin/gitlapi/-/pipelines/9527070386",
474            url
475        );
476    }
477
478    #[test]
479    fn test_list_tags() {
480        let contracts =
481            ResponseContracts::new(ContractType::Gitlab).add_contract(200, "list_tags.json", None);
482        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn RemoteTag);
483        let body_args = ProjectListBodyArgs::builder()
484            .user(None)
485            .from_to_page(None)
486            .tags(true)
487            .build()
488            .unwrap();
489        RemoteTag::list(&*gitlab, body_args).unwrap();
490        assert_eq!(
491            "https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi/repository/tags",
492            *client.url()
493        );
494        assert_eq!(
495            ApiOperation::RepositoryTag,
496            *client.api_operation.borrow().as_ref().unwrap()
497        );
498    }
499
500    #[test]
501    fn test_get_project_tags_num_pages() {
502        let link_header = "<https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi/repository/tags?page=2&per_page=20>; rel=\"next\", <https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi/repository/tags?page=2&per_page=20>; rel=\"last\"";
503        let mut headers = Headers::new();
504        headers.set("link", link_header);
505        let contracts = ResponseContracts::new(ContractType::Gitlab).add_body::<String>(
506            200,
507            None,
508            Some(headers),
509        );
510        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn RemoteTag);
511        let body_args = ProjectListBodyArgs::builder()
512            .user(None)
513            .from_to_page(None)
514            .tags(true)
515            .build()
516            .unwrap();
517        gitlab.num_pages(body_args).unwrap();
518        assert_eq!(
519            "https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi/repository/tags?page=1",
520            *client.url()
521        );
522    }
523
524    #[test]
525    fn test_list_project_members() {
526        let contracts = ResponseContracts::new(ContractType::Gitlab).add_contract(
527            200,
528            "project_members.json",
529            None,
530        );
531        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn ProjectMember);
532        let args = ProjectListBodyArgs::builder()
533            .members(true)
534            .user(None)
535            .from_to_page(None)
536            .build()
537            .unwrap();
538        let members = ProjectMember::list(&*gitlab, args).unwrap();
539        assert_eq!(2, members.len());
540        assert_eq!("test_user_0", members[0].username);
541        assert_eq!("test_user_1", members[1].username);
542        assert_eq!("1234", client.headers().get("PRIVATE-TOKEN").unwrap());
543        assert_eq!(
544            "https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi/members/all",
545            *client.url(),
546        );
547        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
548    }
549
550    #[test]
551    fn test_list_project_members_num_pages() {
552        let link_header = "<https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi/members/all?page=2&per_page=20>; rel=\"next\", <https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi/members/all?page=2&per_page=20>; rel=\"last\"";
553        let mut headers = Headers::new();
554        headers.set("link", link_header);
555        let contracts = ResponseContracts::new(ContractType::Gitlab).add_contract(
556            200,
557            "project_members.json",
558            Some(headers),
559        );
560        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn ProjectMember);
561        let body_args = ProjectListBodyArgs::builder()
562            .members(true)
563            .user(None)
564            .from_to_page(None)
565            .build()
566            .unwrap();
567        gitlab.num_pages(body_args).unwrap();
568        assert_eq!(
569            "https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi/members/all?page=1",
570            *client.url()
571        );
572    }
573
574    #[test]
575    fn test_list_project_members_num_resources() {
576        let contracts = ResponseContracts::new(ContractType::Gitlab).add_contract(
577            200,
578            "project_members.json",
579            None,
580        );
581        let (client, gitlab) = setup_client!(contracts, default_gitlab(), dyn RemoteProject);
582        let body_args = ProjectListBodyArgs::builder()
583            .members(true)
584            .user(None)
585            .from_to_page(None)
586            .build()
587            .unwrap();
588        gitlab.num_resources(body_args).unwrap();
589        assert_eq!(
590            "https://gitlab.com/api/v4/projects/jordilin%2Fgitlapi/members/all?page=1",
591            *client.url()
592        );
593    }
594}