gr/github/
project.rs

1use crate::{
2    api_traits::{ApiOperation, ProjectMember, RemoteProject, RemoteTag},
3    cli::browse::BrowseOptions,
4    cmds::project::{Member, Project, ProjectListBodyArgs, Tag},
5    error::GRError,
6    io::{CmdInfo, HttpResponse, HttpRunner},
7    remote::{query, URLQueryParamBuilder},
8};
9
10use super::Github;
11use crate::Result;
12
13impl<R: HttpRunner<Response = HttpResponse>> RemoteProject for Github<R> {
14    fn get_project_data(&self, id: Option<i64>, path: Option<&str>) -> Result<CmdInfo> {
15        // NOTE: What I call project in here is understood as repository in
16        // Github parlance. In Github there is also the concept of having
17        // projects in a given repository. Getting a repository by ID is not
18        // supported in their REST API.
19        if let Some(id) = id {
20            return Err(GRError::OperationNotSupported(format!(
21                "Getting project data by id is not supported in Github: {}",
22                id
23            ))
24            .into());
25        };
26        let url = if let Some(path) = path {
27            format!("{}/repos/{}", self.rest_api_basepath, path)
28        } else {
29            format!("{}/repos/{}", self.rest_api_basepath, self.path)
30        };
31        let project = query::get::<_, (), Project>(
32            &self.runner,
33            &url,
34            None,
35            self.request_headers(),
36            ApiOperation::Project,
37            |value| GithubProjectFields::from(value).into(),
38        )?;
39        Ok(CmdInfo::Project(project))
40    }
41
42    fn get_project_members(&self) -> Result<CmdInfo> {
43        let url = &format!(
44            "{}/repos/{}/contributors",
45            self.rest_api_basepath, self.path
46        );
47        let members = query::paged(
48            &self.runner,
49            url,
50            None,
51            self.request_headers(),
52            None,
53            ApiOperation::Project,
54            |value| GithubMemberFields::from(value).into(),
55        )?;
56        Ok(CmdInfo::Members(members))
57    }
58
59    fn get_url(&self, option: BrowseOptions) -> String {
60        let base_url = format!("https://{}/{}", self.domain, self.path);
61        match option {
62            BrowseOptions::Repo => base_url,
63            BrowseOptions::MergeRequests => format!("{}/pulls", base_url),
64            BrowseOptions::MergeRequestId(id) => format!("{}/pull/{}", base_url, id),
65            BrowseOptions::Pipelines => format!("{}/actions", base_url),
66            BrowseOptions::PipelineId(id) => format!("{}/actions/runs/{}", base_url, id),
67            BrowseOptions::Releases => format!("{}/releases", base_url),
68            // Manual is only one URL and it's the user guide. Handled in the
69            // browser command.
70            BrowseOptions::Manual => unreachable!(),
71        }
72    }
73
74    fn list(&self, args: crate::cmds::project::ProjectListBodyArgs) -> Result<Vec<Project>> {
75        let url = self.list_project_url(&args, false);
76        let projects = query::paged(
77            &self.runner,
78            &url,
79            args.from_to_page,
80            self.request_headers(),
81            None,
82            ApiOperation::Project,
83            |value| GithubProjectFields::from(value).into(),
84        )?;
85        Ok(projects)
86    }
87
88    fn num_pages(&self, args: ProjectListBodyArgs) -> Result<Option<u32>> {
89        let url = self.list_project_url(&args, true);
90        query::num_pages(
91            &self.runner,
92            &url,
93            self.request_headers(),
94            ApiOperation::Project,
95        )
96    }
97
98    fn num_resources(
99        &self,
100        args: ProjectListBodyArgs,
101    ) -> Result<Option<crate::api_traits::NumberDeltaErr>> {
102        let url = self.list_project_url(&args, true);
103        query::num_resources(
104            &self.runner,
105            &url,
106            self.request_headers(),
107            ApiOperation::Project,
108        )
109    }
110}
111
112impl<R: HttpRunner<Response = HttpResponse>> RemoteTag for Github<R> {
113    // https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repository-tags
114    fn list(&self, args: ProjectListBodyArgs) -> Result<Vec<Tag>> {
115        let url = self.list_project_url(&args, false);
116        let tags = query::paged(
117            &self.runner,
118            &url,
119            args.from_to_page,
120            self.request_headers(),
121            None,
122            ApiOperation::RepositoryTag,
123            |value| GithubRepositoryTagFields::from(value).into(),
124        )?;
125        Ok(tags)
126    }
127}
128
129impl<R: HttpRunner<Response = HttpResponse>> ProjectMember for Github<R> {
130    fn list(&self, args: ProjectListBodyArgs) -> Result<Vec<Member>> {
131        let url = &format!(
132            "{}/repos/{}/contributors",
133            self.rest_api_basepath, self.path
134        );
135        let members = query::paged(
136            &self.runner,
137            url,
138            args.from_to_page,
139            self.request_headers(),
140            None,
141            ApiOperation::Project,
142            |value| GithubMemberFields::from(value).into(),
143        )?;
144        Ok(members)
145    }
146}
147
148pub struct GithubRepositoryTagFields {
149    tags: Tag,
150}
151
152impl From<&serde_json::Value> for GithubRepositoryTagFields {
153    fn from(tag_data: &serde_json::Value) -> Self {
154        GithubRepositoryTagFields {
155            tags: Tag::builder()
156                .name(tag_data["name"].as_str().unwrap().to_string())
157                .sha(tag_data["commit"]["sha"].as_str().unwrap().to_string())
158                // Github response does not provide a created_at field, so set
159                // it up to UNIX epoch.
160                .created_at("1970-01-01T00:00:00Z".to_string())
161                .build()
162                .unwrap(),
163        }
164    }
165}
166
167impl From<GithubRepositoryTagFields> for Tag {
168    fn from(fields: GithubRepositoryTagFields) -> Self {
169        fields.tags
170    }
171}
172
173impl<R> Github<R> {
174    fn list_project_url(&self, args: &ProjectListBodyArgs, num_pages: bool) -> String {
175        let mut url = if args.tags {
176            URLQueryParamBuilder::new(&format!(
177                "{}/repos/{}/tags",
178                self.rest_api_basepath, self.path
179            ))
180        } else if args.members {
181            URLQueryParamBuilder::new(&format!(
182                "{}/repos/{}/contributors",
183                self.rest_api_basepath, self.path
184            ))
185        } else if args.stars {
186            URLQueryParamBuilder::new(&format!("{}/user/starred", self.rest_api_basepath))
187        } else {
188            let username = args.user.as_ref().unwrap().clone().username;
189            // TODO - not needed - just /user/repos would do
190            // See: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user
191            URLQueryParamBuilder::new(&format!(
192                "{}/users/{}/repos",
193                self.rest_api_basepath, username
194            ))
195        };
196        if num_pages {
197            return url.add_param("page", "1").build();
198        }
199        url.build()
200    }
201}
202
203pub struct GithubProjectFields {
204    project: Project,
205}
206
207impl From<&serde_json::Value> for GithubProjectFields {
208    fn from(project_data: &serde_json::Value) -> Self {
209        GithubProjectFields {
210            project: Project::builder()
211                .id(project_data["id"].as_i64().unwrap())
212                .default_branch(project_data["default_branch"].as_str().unwrap().to_string())
213                .html_url(project_data["html_url"].as_str().unwrap().to_string())
214                .created_at(project_data["created_at"].as_str().unwrap().to_string())
215                .description(
216                    project_data["description"]
217                        .as_str()
218                        .unwrap_or_default()
219                        .to_string(),
220                )
221                .language(
222                    project_data["language"]
223                        .as_str()
224                        .unwrap_or_default()
225                        .to_string(),
226                )
227                .build()
228                .unwrap(),
229        }
230    }
231}
232
233impl From<GithubProjectFields> for Project {
234    fn from(fields: GithubProjectFields) -> Self {
235        fields.project
236    }
237}
238
239pub struct GithubMemberFields {
240    member: Member,
241}
242
243impl From<&serde_json::Value> for GithubMemberFields {
244    fn from(member_data: &serde_json::Value) -> Self {
245        GithubMemberFields {
246            member: Member::builder()
247                .id(member_data["id"].as_i64().unwrap())
248                .username(member_data["login"].as_str().unwrap().to_string())
249                .name("".to_string())
250                // Github does not provide created_at field in the response for
251                // Members (aka contributors). Set it to UNIX epoch.
252                .created_at("1970-01-01T00:00:00Z".to_string())
253                .build()
254                .unwrap(),
255        }
256    }
257}
258
259impl From<GithubMemberFields> for Member {
260    fn from(fields: GithubMemberFields) -> Self {
261        fields.member
262    }
263}
264
265#[cfg(test)]
266mod test {
267
268    use crate::{
269        cmds::project::ProjectListBodyArgs,
270        http::Headers,
271        setup_client,
272        test::utils::{default_github, get_contract, ContractType, ResponseContracts},
273    };
274
275    use super::*;
276
277    #[test]
278    fn test_get_project_data_no_id() {
279        let contracts =
280            ResponseContracts::new(ContractType::Github).add_contract(200, "project.json", None);
281        let (client, github) = setup_client!(contracts, default_github(), dyn RemoteProject);
282        github.get_project_data(None, None).unwrap();
283        assert_eq!(
284            "https://api.github.com/repos/jordilin/githapi",
285            *client.url(),
286        );
287        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
288    }
289
290    #[test]
291    fn test_get_project_data_given_owner_repo_path() {
292        let contracts =
293            ResponseContracts::new(ContractType::Github).add_contract(200, "project.json", None);
294        let (client, github) = setup_client!(contracts, default_github(), dyn RemoteProject);
295        let result = github.get_project_data(None, Some("jordilin/gitar"));
296        assert_eq!("https://api.github.com/repos/jordilin/gitar", *client.url(),);
297        match result {
298            Ok(CmdInfo::Project(project)) => {
299                assert_eq!(123456, project.id);
300            }
301            _ => panic!("Expected project data"),
302        }
303    }
304
305    #[test]
306    fn test_get_project_data_with_id_not_supported() {
307        let contracts = ResponseContracts::new(ContractType::Github);
308        let (_, github) = setup_client!(contracts, default_github(), dyn RemoteProject);
309        assert!(github.get_project_data(Some(1), None).is_err());
310    }
311
312    #[test]
313    fn test_list_current_user_projects() {
314        let contracts = ResponseContracts::new(ContractType::Github).add_body(
315            200,
316            Some(format!(
317                "[{}]",
318                get_contract(ContractType::Github, "project.json")
319            )),
320            None,
321        );
322        let (client, github) = setup_client!(contracts, default_github(), dyn RemoteProject);
323        let body_args = ProjectListBodyArgs::builder()
324            .from_to_page(None)
325            .user(Some(
326                Member::builder()
327                    .id(1)
328                    .name("jdoe".to_string())
329                    .username("jdoe".to_string())
330                    .build()
331                    .unwrap(),
332            ))
333            .build()
334            .unwrap();
335        let projects = github.list(body_args).unwrap();
336        assert_eq!(1, projects.len());
337        assert_eq!("https://api.github.com/users/jdoe/repos", *client.url());
338        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
339    }
340
341    #[test]
342    fn test_get_my_starred_projects() {
343        let contracts =
344            ResponseContracts::new(ContractType::Github).add_contract(200, "stars.json", None);
345        let (client, github) = setup_client!(contracts, default_github(), dyn RemoteProject);
346        let body_args = ProjectListBodyArgs::builder()
347            .from_to_page(None)
348            .user(Some(
349                Member::builder()
350                    .id(1)
351                    .name("jdoe".to_string())
352                    .username("jdoe".to_string())
353                    .build()
354                    .unwrap(),
355            ))
356            .stars(true)
357            .build()
358            .unwrap();
359        let projects = github.list(body_args).unwrap();
360        assert_eq!(1, projects.len());
361        assert_eq!("https://api.github.com/user/starred", *client.url());
362        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
363    }
364
365    #[test]
366    fn test_get_project_num_pages_url_for_user() {
367        let link_header = "<https://api.github.com/users/jdoe/repos?page=2>; rel=\"next\", <https://api.github.com/users/jdoe/repos?page=2>; rel=\"last\"";
368        let mut headers = Headers::new();
369        headers.set("link", link_header);
370        let contracts = ResponseContracts::new(ContractType::Github).add_body::<String>(
371            200,
372            None,
373            Some(headers),
374        );
375        let (client, github) = setup_client!(contracts, default_github(), dyn RemoteProject);
376        let body_args = ProjectListBodyArgs::builder()
377            .from_to_page(None)
378            .user(Some(
379                Member::builder()
380                    .id(1)
381                    .name("jdoe".to_string())
382                    .username("jdoe".to_string())
383                    .build()
384                    .unwrap(),
385            ))
386            .build()
387            .unwrap();
388        github.num_pages(body_args).unwrap();
389        assert_eq!(
390            "https://api.github.com/users/jdoe/repos?page=1",
391            *client.url()
392        );
393        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
394    }
395
396    #[test]
397    fn test_get_project_num_pages_url_for_starred() {
398        let link_header = "<https://api.github.com/user/starred?page=2>; rel=\"next\", <https://api.github.com/user/starred?page=2>; rel=\"last\"";
399        let mut headers = Headers::new();
400        headers.set("link", link_header);
401        let contracts = ResponseContracts::new(ContractType::Github).add_body::<String>(
402            200,
403            None,
404            Some(headers),
405        );
406        let (client, github) = setup_client!(contracts, default_github(), dyn RemoteProject);
407        let body_args = ProjectListBodyArgs::builder()
408            .from_to_page(None)
409            .user(Some(
410                Member::builder()
411                    .id(1)
412                    .name("jdoe".to_string())
413                    .username("jdoe".to_string())
414                    .build()
415                    .unwrap(),
416            ))
417            .stars(true)
418            .build()
419            .unwrap();
420        github.num_pages(body_args).unwrap();
421        assert_eq!("https://api.github.com/user/starred?page=1", *client.url());
422        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
423    }
424
425    #[test]
426    fn test_get_url_pipeline_id() {
427        let contracts = ResponseContracts::new(ContractType::Github);
428        let (_, github) = setup_client!(contracts, default_github(), dyn RemoteProject);
429        let url = github.get_url(BrowseOptions::PipelineId(9527070386));
430        assert_eq!(
431            "https://github.com/jordilin/githapi/actions/runs/9527070386",
432            url
433        );
434    }
435
436    #[test]
437    fn test_list_project_tags() {
438        let contracts =
439            ResponseContracts::new(ContractType::Github).add_contract(200, "list_tags.json", None);
440        let (client, github) = setup_client!(contracts, default_github(), dyn RemoteTag);
441        let body_args = ProjectListBodyArgs::builder()
442            .user(None)
443            .from_to_page(None)
444            .tags(true)
445            .build()
446            .unwrap();
447        let tags = RemoteTag::list(&*github, body_args).unwrap();
448        assert_eq!(1, tags.len());
449        assert_eq!(
450            "https://api.github.com/repos/jordilin/githapi/tags",
451            *client.url()
452        );
453        assert_eq!(
454            Some(ApiOperation::RepositoryTag),
455            *client.api_operation.borrow()
456        );
457    }
458
459    #[test]
460    fn test_get_project_tags_num_pages() {
461        let link_header = "<https://api.github.com/repos/jordilin/githapi/tags?page=2>; rel=\"next\", <https://api.github.com/repos/jordilin/githapi/tags?page=2>; rel=\"last\"";
462        let mut headers = Headers::new();
463        headers.set("link", link_header);
464        let contracts = ResponseContracts::new(ContractType::Github).add_body::<String>(
465            200,
466            None,
467            Some(headers),
468        );
469        let (client, github) = setup_client!(contracts, default_github(), dyn RemoteTag);
470        let body_args = ProjectListBodyArgs::builder()
471            .user(None)
472            .from_to_page(None)
473            .tags(true)
474            .build()
475            .unwrap();
476        github.num_pages(body_args).unwrap();
477        assert_eq!(
478            "https://api.github.com/repos/jordilin/githapi/tags?page=1",
479            *client.url()
480        );
481    }
482
483    #[test]
484    fn test_list_project_members() {
485        let contracts = ResponseContracts::new(ContractType::Github).add_contract(
486            200,
487            "project_members.json",
488            None,
489        );
490        let (client, github) = setup_client!(contracts, default_github(), dyn ProjectMember);
491        let args = ProjectListBodyArgs::builder()
492            .members(true)
493            .user(None)
494            .from_to_page(None)
495            .build()
496            .unwrap();
497        let members = ProjectMember::list(&*github, args).unwrap();
498        assert_eq!(1, members.len());
499        assert_eq!("octocat", members[0].username);
500        assert_eq!(
501            "bearer 1234",
502            client.headers().get("Authorization").unwrap()
503        );
504        assert_eq!(
505            "https://api.github.com/repos/jordilin/githapi/contributors",
506            *client.url()
507        );
508        assert_eq!(Some(ApiOperation::Project), *client.api_operation.borrow());
509    }
510
511    #[test]
512    fn test_project_members_num_pages() {
513        let link_header = "<https://api.github.com/repos/jordilin/githapi/contributors?page=2>; rel=\"next\", <https://api.github.com/repos/jordilin/githapi/contributors?page=2>; rel=\"last\"";
514        let mut headers = Headers::new();
515        headers.set("link", link_header);
516        let contracts = ResponseContracts::new(ContractType::Github).add_body::<String>(
517            200,
518            None,
519            Some(headers),
520        );
521        let (client, github) = setup_client!(contracts, default_github(), dyn ProjectMember);
522        let args = ProjectListBodyArgs::builder()
523            .members(true)
524            .user(None)
525            .from_to_page(None)
526            .build()
527            .unwrap();
528        github.num_pages(args).unwrap();
529        assert_eq!(
530            "https://api.github.com/repos/jordilin/githapi/contributors?page=1",
531            *client.url()
532        );
533    }
534
535    #[test]
536    fn test_get_project_members_num_resources() {
537        let contracts = ResponseContracts::new(ContractType::Github).add_contract(
538            200,
539            "project_members.json",
540            None,
541        );
542        let (client, github) = setup_client!(contracts, default_github(), dyn ProjectMember);
543        let args = ProjectListBodyArgs::builder()
544            .members(true)
545            .user(None)
546            .from_to_page(None)
547            .build()
548            .unwrap();
549        github.num_resources(args).unwrap();
550        assert_eq!(
551            "https://api.github.com/repos/jordilin/githapi/contributors?page=1",
552            *client.url()
553        );
554    }
555}