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