gr/github/
merge_request.rs

1use super::Github;
2use crate::{
3    api_traits::{ApiOperation, CommentMergeRequest, MergeRequest, NumberDeltaErr, RemoteProject},
4    cli::browse::BrowseOptions,
5    cmds::{
6        merge_request::{
7            Comment, CommentMergeRequestBodyArgs, CommentMergeRequestListBodyArgs,
8            MergeRequestBodyArgs, MergeRequestListBodyArgs, MergeRequestResponse,
9            MergeRequestState,
10        },
11        project::MrMemberType,
12    },
13    http::{self, Body},
14    io::{HttpResponse, HttpRunner},
15    json_loads,
16    remote::query,
17};
18
19use crate::{error, Result};
20
21impl<R> Github<R> {
22    fn url_list_merge_requests(&self, args: &MergeRequestListBodyArgs) -> String {
23        let state = match args.state {
24            MergeRequestState::Opened => "open".to_string(),
25            // Github has no distinction between closed and merged. A merged
26            // pull request is considered closed.
27            MergeRequestState::Closed | MergeRequestState::Merged => "closed".to_string(),
28        };
29        if args.assignee.is_some() {
30            return format!(
31                "{}/issues?state={}&filter=assigned",
32                self.rest_api_basepath, state
33            );
34        } else if args.author.is_some() {
35            return format!(
36                "{}/issues?state={}&filter=created",
37                self.rest_api_basepath, state
38            );
39        }
40        format!(
41            "{}/repos/{}/pulls?state={}",
42            self.rest_api_basepath, self.path, state
43        )
44    }
45
46    fn resource_comments_metadata_url(&self, args: CommentMergeRequestListBodyArgs) -> String {
47        let url = format!(
48            "{}/repos/{}/issues/{}/comments?page=1",
49            self.rest_api_basepath, self.path, args.id
50        );
51        url
52    }
53}
54
55impl<R: HttpRunner<Response = HttpResponse>> MergeRequest for Github<R> {
56    fn open(&self, args: MergeRequestBodyArgs) -> Result<MergeRequestResponse> {
57        // https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#create-a-pull-request
58        let mut body = Body::new();
59        body.add("base", args.target_branch);
60        body.add("title", args.title);
61        body.add("body", args.description);
62        // Add draft in payload only when requested. It seems that Github opens
63        // PR in draft mode even when the draft value is false.
64        if args.draft {
65            body.add("draft", args.draft.to_string());
66        }
67        let target_repo = args.target_repo.clone();
68        let mut mr_url = format!(
69            "{}/repos/{}/pulls",
70            self.rest_api_basepath,
71            self.path.clone()
72        );
73        if !target_repo.is_empty() {
74            mr_url = format!(
75                "{}/repos/{}/pulls",
76                self.rest_api_basepath, args.target_repo
77            );
78            let owner_path = self.path.split('/').collect::<Vec<&str>>();
79            if owner_path.len() != 2 {
80                return Err(error::GRError::ApplicationError(format!(
81                    "Invalid path format in git config: [{}] while attempting \
82                    to retrieve existing pull request. Expected owner/repo",
83                    self.path
84                ))
85                .into());
86            }
87            let remote_pr_branch = format!("{}:{}", owner_path[0], args.source_branch.clone());
88            body.add("head", remote_pr_branch);
89        } else {
90            body.add("head", args.source_branch.clone());
91        }
92
93        match query::send_raw(
94            &self.runner,
95            &mr_url,
96            Some(&body),
97            self.request_headers(),
98            ApiOperation::MergeRequest,
99            http::Method::POST,
100        ) {
101            Ok(response) => {
102                match response.status {
103                    201 => {
104                        // This is a new pull request
105                        // Set the assignee to the pull request. Currently, the
106                        // only way to set the assignee to a pull request is by
107                        // using the issues API. All pull requests in Github API v3
108                        // are considered to be issues, but not the other way
109                        // around.
110                        // See note in https://docs.github.com/en/rest/issues/issues#list-repository-issues
111                        // Note: Github's REST API v3 considers every pull request
112                        // an issue, but not every issue is a pull request.
113                        // https://docs.github.com/en/rest/issues/issues#update-an-issue
114                        let body = response.body;
115                        let merge_request_json = json_loads(&body)?;
116                        let id = merge_request_json["number"].as_i64().unwrap();
117                        match args.assignee.mr_member_type {
118                            MrMemberType::Empty => (),
119                            MrMemberType::Filled => {
120                                // Assignee API
121                                // https://docs.github.com/en/rest/issues/issues#update-an-issue
122                                let issues_url = format!(
123                                    "{}/repos/{}/issues/{}",
124                                    self.rest_api_basepath, self.path, id
125                                );
126                                let mut body = Body::new();
127                                let assignees = vec![args.assignee.username.as_str()];
128                                body.add("assignees", &assignees);
129                                query::send_raw(
130                                    &self.runner,
131                                    &issues_url,
132                                    Some(&body),
133                                    self.request_headers(),
134                                    ApiOperation::MergeRequest,
135                                    http::Method::PATCH,
136                                )?;
137                            }
138                        }
139                        // Requested reviewers API
140                        // https://docs.github.com/en/rest/pulls/review-requests?apiVersion=2022-11-28#request-reviewers-for-a-pull-request
141                        match args.reviewer.mr_member_type {
142                            MrMemberType::Empty => (),
143                            MrMemberType::Filled => {
144                                let mut body = Body::new();
145                                let reviewers = vec![args.reviewer.username.as_str()];
146                                body.add("reviewers", &reviewers);
147                                let requested_reviewers_url =
148                                    format!("{mr_url}/{id}/requested_reviewers");
149
150                                let response = query::send_raw(
151                                    &self.runner,
152                                    &requested_reviewers_url,
153                                    Some(&body),
154                                    self.request_headers(),
155                                    ApiOperation::MergeRequest,
156                                    http::Method::POST,
157                                )?;
158                                // Consider 422 failure - Reviewer not a collaborator
159                                if response.status != 201 {
160                                    return Err(query::query_error(
161                                        &requested_reviewers_url,
162                                        &response,
163                                    )
164                                    .into());
165                                }
166                            }
167                        }
168                        Ok(GithubMergeRequestFields::from(&merge_request_json).into())
169                    }
170                    422 => {
171                        // There is an existing pull request already.
172                        // Gather its URL by querying Github pull requests filtering by
173                        // head owner:branch
174                        // Ref:
175                        // https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests--parameters
176
177                        // The path has owner/repo format.
178                        let owner_path = self.path.split('/').collect::<Vec<&str>>();
179                        if owner_path.len() != 2 {
180                            return Err(error::GRError::ApplicationError(format!(
181                                "Invalid path format in git config: [{}] while attempting \
182                                to retrieve existing pull request. Expected owner/repo",
183                                self.path
184                            ))
185                            .into());
186                        }
187                        let remote_pr_branch = format!("{}:{}", owner_path[0], args.source_branch);
188                        let existing_mr_url = format!("{mr_url}?head={remote_pr_branch}");
189                        let response = query::get_raw::<_, ()>(
190                            &self.runner,
191                            &existing_mr_url,
192                            None,
193                            self.request_headers(),
194                            ApiOperation::MergeRequest,
195                        )?;
196                        let merge_requests_json: Vec<serde_json::Value> =
197                            serde_json::from_str(&response.body)?;
198                        if merge_requests_json.len() == 1 {
199                            let mr_id = merge_requests_json[0]["number"].as_i64().unwrap();
200                            if args.amend {
201                                // Amend the existing pull request
202                                let url = format!(
203                                    "{}/repos/{}/pulls/{}",
204                                    self.rest_api_basepath, self.path, mr_id
205                                );
206                                query::send_json::<_, String>(
207                                    &self.runner,
208                                    &url,
209                                    Some(&body),
210                                    self.request_headers(),
211                                    ApiOperation::MergeRequest,
212                                    http::Method::PATCH,
213                                )?;
214                            }
215                            Ok(MergeRequestResponse::builder()
216                                .id(mr_id)
217                                .web_url(
218                                    merge_requests_json[0]["html_url"]
219                                        .to_string()
220                                        .trim_matches('"')
221                                        .to_string(),
222                                )
223                                .build()
224                                .unwrap())
225                        } else {
226                            Err(error::GRError::RemoteUnexpectedResponseContract(format!(
227                                "There should have been an existing pull request at \
228                                URL: {} but got an unexpected response: {}",
229                                existing_mr_url, response.body
230                            ))
231                            .into())
232                        }
233                    }
234                    _ => Err(error::gen(format!(
235                        "Failed to create merge request. Status code: {}, Body: {}",
236                        response.status, response.body
237                    ))),
238                }
239            }
240            Err(err) => Err(err),
241        }
242    }
243
244    fn list(&self, args: MergeRequestListBodyArgs) -> Result<Vec<MergeRequestResponse>> {
245        let url = self.url_list_merge_requests(&args);
246        let response = query::paged::<_, MergeRequestResponse>(
247            &self.runner,
248            &url,
249            args.list_args,
250            self.request_headers(),
251            None,
252            ApiOperation::MergeRequest,
253            |value| GithubMergeRequestFields::from(value).into(),
254        );
255        if args.assignee.is_some() || args.author.is_some() {
256            // Pull requests for the current authenticated user.
257            // Filter those responses that have pull_request not empty See ref:
258            // https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#list-issues-assigned-to-the-authenticated-user
259            // Quoting Github's docs: Note: GitHub's REST API considers every
260            // pull request an issue, but not every issue is a pull request. For
261            // this reason, "Issues" endpoints may return both issues and pull
262            // requests in the response. You can identify pull requests by the
263            // pull_request key. Be aware that the id of a pull request returned
264            // from "Issues" endpoints will be an issue id. To find out the pull
265            // request id, use the "List pull requests" endpoint.
266            let mut merge_requests = vec![];
267            for mr in response? {
268                if !mr.pull_request.is_empty() {
269                    merge_requests.push(mr);
270                }
271            }
272            return Ok(merge_requests);
273        }
274        response
275    }
276
277    fn merge(&self, id: i64) -> Result<MergeRequestResponse> {
278        // https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#merge-a-pull-request
279        //  /repos/{owner}/{repo}/pulls/{pull_number}/merge
280        let url = format!(
281            "{}/repos/{}/pulls/{}/merge",
282            self.rest_api_basepath, self.path, id
283        );
284        query::send_json::<_, ()>(
285            &self.runner,
286            &url,
287            None,
288            self.request_headers(),
289            ApiOperation::MergeRequest,
290            http::Method::PUT,
291        )?;
292        // Response:
293        // {
294        //     "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e",
295        //     "merged": true,
296        //     "message": "Pull Request successfully merged"
297        // }
298
299        // We do not have the id nor the url available in the response. Compute
300        // it and return it to the client so we can open the url if needed.
301        Ok(MergeRequestResponse::builder()
302            .id(id)
303            .web_url(self.get_url(BrowseOptions::MergeRequestId(id)))
304            .build()
305            .unwrap())
306    }
307
308    fn get(&self, id: i64) -> Result<MergeRequestResponse> {
309        let url = format!(
310            "{}/repos/{}/pulls/{}",
311            self.rest_api_basepath, self.path, id
312        );
313        query::get::<_, (), _>(
314            &self.runner,
315            &url,
316            None,
317            self.request_headers(),
318            ApiOperation::MergeRequest,
319            |value| GithubMergeRequestFields::from(value).into(),
320        )
321    }
322
323    fn close(&self, id: i64) -> Result<MergeRequestResponse> {
324        let url = format!(
325            "{}/repos/{}/pulls/{}",
326            self.rest_api_basepath, self.path, id
327        );
328        let mut body = Body::new();
329        body.add("state", "closed");
330        query::send::<_, &str, _>(
331            &self.runner,
332            &url,
333            Some(&body),
334            self.request_headers(),
335            ApiOperation::MergeRequest,
336            |value| GithubMergeRequestFields::from(value).into(),
337            http::Method::PATCH,
338        )
339    }
340
341    fn num_pages(&self, args: MergeRequestListBodyArgs) -> Result<Option<u32>> {
342        let url = self.url_list_merge_requests(&args) + "&page=1";
343        let headers = self.request_headers();
344        query::num_pages(&self.runner, &url, headers, ApiOperation::MergeRequest)
345    }
346
347    fn num_resources(&self, args: MergeRequestListBodyArgs) -> Result<Option<NumberDeltaErr>> {
348        let url = self.url_list_merge_requests(&args) + "&page=1";
349        let headers = self.request_headers();
350        query::num_resources(&self.runner, &url, headers, ApiOperation::MergeRequest)
351    }
352
353    fn approve(&self, _id: i64) -> Result<MergeRequestResponse> {
354        todo!()
355    }
356}
357
358impl<R: HttpRunner<Response = HttpResponse>> CommentMergeRequest for Github<R> {
359    fn create(&self, args: CommentMergeRequestBodyArgs) -> Result<()> {
360        let url = format!(
361            "{}/repos/{}/issues/{}/comments",
362            self.rest_api_basepath, self.path, args.id
363        );
364        let mut body = Body::new();
365        body.add("body", args.comment);
366        query::send_raw(
367            &self.runner,
368            &url,
369            Some(&body),
370            self.request_headers(),
371            ApiOperation::MergeRequest,
372            http::Method::POST,
373        )?;
374        Ok(())
375    }
376
377    fn list(&self, args: CommentMergeRequestListBodyArgs) -> Result<Vec<Comment>> {
378        let url = format!(
379            "{}/repos/{}/issues/{}/comments",
380            self.rest_api_basepath, self.path, args.id
381        );
382        query::paged(
383            &self.runner,
384            &url,
385            args.list_args,
386            self.request_headers(),
387            None,
388            ApiOperation::MergeRequest,
389            |value| GithubMergeRequestCommentFields::from(value).into(),
390        )
391    }
392
393    fn num_pages(&self, args: CommentMergeRequestListBodyArgs) -> Result<Option<u32>> {
394        let url = self.resource_comments_metadata_url(args);
395        query::num_pages(
396            &self.runner,
397            &url,
398            self.request_headers(),
399            ApiOperation::MergeRequest,
400        )
401    }
402
403    fn num_resources(
404        &self,
405        args: CommentMergeRequestListBodyArgs,
406    ) -> Result<Option<NumberDeltaErr>> {
407        let url = self.resource_comments_metadata_url(args);
408        query::num_resources(
409            &self.runner,
410            &url,
411            self.request_headers(),
412            ApiOperation::MergeRequest,
413        )
414    }
415}
416
417pub struct GithubMergeRequestFields {
418    fields: MergeRequestResponse,
419}
420
421impl From<&serde_json::Value> for GithubMergeRequestFields {
422    fn from(merge_request_data: &serde_json::Value) -> Self {
423        GithubMergeRequestFields {
424            fields: MergeRequestResponse::builder()
425                .id(merge_request_data["number"].as_i64().unwrap())
426                .web_url(merge_request_data["html_url"].as_str().unwrap().to_string())
427                .source_branch(
428                    merge_request_data["head"]["ref"]
429                        .as_str()
430                        .unwrap_or_default()
431                        .to_string(),
432                )
433                .sha(
434                    merge_request_data["merge_commit_sha"]
435                        .as_str()
436                        .unwrap_or_default()
437                        .to_string(),
438                )
439                .author(
440                    merge_request_data["user"]["login"]
441                        .as_str()
442                        .unwrap_or_default()
443                        .to_string(),
444                )
445                .updated_at(
446                    merge_request_data["updated_at"]
447                        .as_str()
448                        .unwrap_or_default()
449                        .to_string(),
450                )
451                .created_at(
452                    merge_request_data["created_at"]
453                        .as_str()
454                        .unwrap_or_default()
455                        .to_string(),
456                )
457                .title(
458                    merge_request_data["title"]
459                        .as_str()
460                        .unwrap_or_default()
461                        .to_string(),
462                )
463                .pull_request(
464                    merge_request_data["pull_request"]["html_url"]
465                        .as_str()
466                        .unwrap_or_default()
467                        .to_string(),
468                )
469                .description(
470                    merge_request_data["body"]
471                        .as_str()
472                        .unwrap_or_default()
473                        .to_string(),
474                )
475                .merged_at(
476                    merge_request_data["merged_at"]
477                        .as_str()
478                        .unwrap_or_default()
479                        .to_string(),
480                )
481                // Not available in the response. Set it to the same ID as the pull request
482                .pipeline_id(Some(merge_request_data["number"].as_i64().unwrap()))
483                .pipeline_url(
484                    merge_request_data["html_url"]
485                        .as_str()
486                        .map(|url| format!("{url}/checks")),
487                )
488                .build()
489                .unwrap(),
490        }
491    }
492}
493
494impl From<GithubMergeRequestFields> for MergeRequestResponse {
495    fn from(fields: GithubMergeRequestFields) -> Self {
496        fields.fields
497    }
498}
499
500pub struct GithubMergeRequestCommentFields {
501    comment: Comment,
502}
503
504impl From<&serde_json::Value> for GithubMergeRequestCommentFields {
505    fn from(comment_data: &serde_json::Value) -> Self {
506        GithubMergeRequestCommentFields {
507            comment: Comment::builder()
508                .id(comment_data["id"].as_i64().unwrap())
509                .author(comment_data["user"]["login"].as_str().unwrap().to_string())
510                .created_at(comment_data["created_at"].as_str().unwrap().to_string())
511                .body(comment_data["body"].as_str().unwrap().to_string())
512                .build()
513                .unwrap(),
514        }
515    }
516}
517
518impl From<GithubMergeRequestCommentFields> for Comment {
519    fn from(fields: GithubMergeRequestCommentFields) -> Self {
520        fields.comment
521    }
522}
523
524#[cfg(test)]
525mod test {
526
527    use crate::{
528        cmds::project::{Member, MrMemberType},
529        http::{self, Headers},
530        remote::ListBodyArgs,
531        setup_client,
532        test::utils::{
533            default_github, get_contract, BasePath, ClientType, ContractType, Domain,
534            ResponseContracts,
535        },
536    };
537
538    use super::*;
539
540    #[test]
541    fn test_open_merge_request() {
542        let responses = ResponseContracts::new(ContractType::Github)
543            .add_contract(201, "merge_request.json", None)
544            .add_contract(200, "merge_request.json", None)
545            .add_contract(201, "merge_request.json", None);
546        let (client, github) = setup_client!(responses, default_github(), dyn MergeRequest);
547        let assignee = Member::builder()
548            .name("tom".to_string())
549            .username("tsawyer".to_string())
550            .mr_member_type(MrMemberType::Filled)
551            .id(1234)
552            .build()
553            .unwrap();
554        let reviewer = Member::builder()
555            .name("huck".to_string())
556            .username("hfinn".to_string())
557            .mr_member_type(MrMemberType::Filled)
558            .id(45678)
559            .build()
560            .unwrap();
561        let mr_args = MergeRequestBodyArgs::builder()
562            .assignee(assignee)
563            .reviewer(reviewer)
564            .build()
565            .unwrap();
566        let response = github.open(mr_args).unwrap();
567        assert_eq!(23, response.id);
568        assert_eq!(
569            "https://github.com/jordilin/githapi/pull/23",
570            response.web_url
571        );
572        assert_eq!(
573            "https://api.github.com/repos/jordilin/githapi/pulls/23/requested_reviewers",
574            *client.url(),
575        );
576        let actual_method = client.http_method.borrow();
577        assert_eq!(http::Method::POST, actual_method[0]);
578        // Assignee call
579        assert_eq!(http::Method::PATCH, actual_method[1]);
580        // Reviewer call
581        assert_eq!(http::Method::POST, actual_method[2]);
582        assert_eq!(
583            Some(ApiOperation::MergeRequest),
584            *client.api_operation.borrow()
585        );
586    }
587
588    #[test]
589    fn test_open_merge_request_with_assignee_no_reviewer() {
590        let responses = ResponseContracts::new(ContractType::Github)
591            .add_contract(200, "merge_request.json", None)
592            .add_contract(201, "merge_request.json", None);
593        let (client, github) = setup_client!(responses, default_github(), dyn MergeRequest);
594        let assignee = Member::builder()
595            .name("tom".to_string())
596            .username("tsawyer".to_string())
597            .mr_member_type(MrMemberType::Filled)
598            .id(1234)
599            .build()
600            .unwrap();
601        let reviewer = Member::default(); // Default member is empty/placeholder
602        let mr_args = MergeRequestBodyArgs::builder()
603            .assignee(assignee)
604            .reviewer(reviewer)
605            .build()
606            .unwrap();
607        let response = github.open(mr_args).unwrap();
608        assert_eq!(23, response.id);
609        assert_eq!(
610            "https://github.com/jordilin/githapi/pull/23",
611            response.web_url
612        );
613        assert_eq!(
614            "https://api.github.com/repos/jordilin/githapi/issues/23",
615            *client.url(),
616        );
617        let actual_method = client.http_method.borrow();
618        assert_eq!(http::Method::PATCH, actual_method[1]);
619        // The open create merge request was a POST.
620        assert_eq!(http::Method::POST, actual_method[0]);
621        assert_eq!(
622            Some(ApiOperation::MergeRequest),
623            *client.api_operation.borrow()
624        );
625    }
626
627    #[test]
628    fn test_open_merge_request_with_reviewer_no_assignee() {
629        let responses = ResponseContracts::new(ContractType::Github)
630            .add_contract(201, "merge_request.json", None)
631            .add_contract(201, "merge_request.json", None);
632        let (client, github) = setup_client!(responses, default_github(), dyn MergeRequest);
633        let assignee = Member::default();
634        let reviewer = Member::builder()
635            .name("huck".to_string())
636            .username("hfinn".to_string())
637            .mr_member_type(MrMemberType::Filled)
638            .id(1234)
639            .build()
640            .unwrap();
641        let mr_args = MergeRequestBodyArgs::builder()
642            .assignee(assignee)
643            .reviewer(reviewer)
644            .build()
645            .unwrap();
646        assert!(github.open(mr_args).is_ok());
647        assert_eq!(
648            "https://api.github.com/repos/jordilin/githapi/pulls/23/requested_reviewers",
649            *client.url(),
650        );
651        let actual_method = client.http_method.borrow();
652        // POST on requesting a reviewer
653        assert_eq!(http::Method::POST, actual_method[1]);
654        // The open create merge request was a POST.
655        assert_eq!(http::Method::POST, actual_method[0]);
656        assert_eq!(
657            Some(ApiOperation::MergeRequest),
658            *client.api_operation.borrow()
659        );
660    }
661
662    #[test]
663    fn test_open_merge_request_with_no_assignee_no_reviewer() {
664        let responses = ResponseContracts::new(ContractType::Github).add_contract(
665            201,
666            "merge_request.json",
667            None,
668        );
669        let (client, github) = setup_client!(responses, default_github(), dyn MergeRequest);
670        let assignee = Member::default();
671        let reviewer = Member::default();
672        let mr_args = MergeRequestBodyArgs::builder()
673            .assignee(assignee)
674            .reviewer(reviewer)
675            .build()
676            .unwrap();
677        assert!(github.open(mr_args).is_ok());
678        assert_eq!(
679            "https://api.github.com/repos/jordilin/githapi/pulls",
680            *client.url(),
681        );
682        let actual_method = client.http_method.borrow();
683        // The open create merge request was a POST.
684        assert_eq!(http::Method::POST, actual_method[0]);
685        assert_eq!(
686            Some(ApiOperation::MergeRequest),
687            *client.api_operation.borrow()
688        );
689    }
690
691    #[test]
692    fn test_open_merge_request_on_target_repository() {
693        let mr_args = MergeRequestBodyArgs::builder()
694            .target_repo("jordilin/gitar".to_string())
695            .build()
696            .unwrap();
697        let responses = ResponseContracts::new(ContractType::Github)
698            .add_contract(200, "merge_request.json", None)
699            .add_contract(201, "merge_request.json", None);
700        // current repo, targeting jordilin/gitar
701        let client_type = ClientType::Github(
702            Domain("github.com".to_string()),
703            BasePath("jdoe/gitar".to_string()),
704        );
705        let (client, github) = setup_client!(responses, client_type, dyn MergeRequest);
706
707        assert!(github.open(mr_args).is_ok());
708        assert_eq!(
709            "https://api.github.com/repos/jordilin/gitar/pulls",
710            *client.url(),
711        );
712        assert_eq!(
713            Some(ApiOperation::MergeRequest),
714            *client.api_operation.borrow()
715        );
716    }
717
718    #[test]
719    fn test_open_merge_request_with_no_assignee() {
720        let responses = ResponseContracts::new(ContractType::Github)
721            .add_contract(200, "merge_request.json", None)
722            .add_contract(201, "merge_request.json", None);
723        let (client, github) = setup_client!(responses, default_github(), dyn MergeRequest);
724        let assignee = Member::default();
725        let mr_args = MergeRequestBodyArgs::builder()
726            .assignee(assignee)
727            .build()
728            .unwrap();
729        assert!(github.open(mr_args).is_ok());
730        assert_eq!(
731            "https://api.github.com/repos/jordilin/githapi/pulls",
732            *client.url(),
733        );
734        let actual_method = client.http_method.borrow();
735        assert_eq!(http::Method::POST, actual_method[0]);
736        assert_eq!(
737            Some(ApiOperation::MergeRequest),
738            *client.api_operation.borrow()
739        );
740        let actual_body = client.request_body.borrow();
741        assert!(!actual_body.contains("assignees"));
742    }
743
744    #[test]
745    fn test_open_merge_request_error_status_code() {
746        let mr_args = MergeRequestBodyArgs::builder().build().unwrap();
747        let responses = ResponseContracts::new(ContractType::Github).add_body(
748            401,
749            Some(r#"{"message":"Bad credentials","documentation_url":"https://docs.github.com/rest"}"#),
750            None,
751        );
752        let (_, github) = setup_client!(responses, default_github(), dyn MergeRequest);
753        assert!(github.open(mr_args).is_err());
754    }
755
756    #[test]
757    fn test_open_merge_request_existing_one() {
758        let mr_args = MergeRequestBodyArgs::builder()
759            .source_branch("feature".to_string())
760            .build()
761            .unwrap();
762        let contracts = ResponseContracts::new(ContractType::Github)
763            .add_body(
764                200,
765                Some(format!(
766                    "[{}]",
767                    get_contract(ContractType::Github, "merge_request.json")
768                )),
769                None,
770            )
771            // Github returns a 422 (already exists), so the code grabs existing URL
772            // filtering by namespace and branch. The response is a list of merge
773            // requests.
774            .add_contract(422, "merge_request_conflict.json", None);
775        let (client, github) = setup_client!(contracts, default_github(), dyn MergeRequest);
776
777        github.open(mr_args).unwrap();
778        assert_eq!(
779            "https://api.github.com/repos/jordilin/githapi/pulls?head=jordilin:feature",
780            *client.url(),
781        );
782        let actual_method = client.http_method.borrow();
783        assert_eq!(http::Method::GET, actual_method[1]);
784        assert_eq!(
785            Some(ApiOperation::MergeRequest),
786            *client.api_operation.borrow()
787        );
788    }
789
790    #[test]
791    fn test_amend_existing_pull_request() {
792        let mr_args = MergeRequestBodyArgs::builder()
793            .source_branch("feature".to_string())
794            .amend(true)
795            .build()
796            .unwrap();
797        let contracts = ResponseContracts::new(ContractType::Github)
798            .add_contract(200, "merge_request.json", None)
799            .add_body(
800                200,
801                Some(format!(
802                    "[{}]",
803                    get_contract(ContractType::Github, "merge_request.json")
804                )),
805                None,
806            )
807            .add_contract(422, "merge_request_conflict.json", None);
808
809        let (client, github) = setup_client!(contracts, default_github(), dyn MergeRequest);
810
811        github.open(mr_args).unwrap();
812        assert_eq!(
813            "https://api.github.com/repos/jordilin/githapi/pulls/23",
814            *client.url(),
815        );
816        let actual_method = client.http_method.borrow();
817        assert_eq!(http::Method::PATCH, actual_method[2]);
818        assert_eq!(
819            Some(ApiOperation::MergeRequest),
820            *client.api_operation.borrow()
821        );
822    }
823
824    #[test]
825    fn test_open_merge_request_cannot_retrieve_url_existing_one_is_error() {
826        let mr_args = MergeRequestBodyArgs::builder()
827            .source_branch("feature".to_string())
828            .build()
829            .unwrap();
830        let contracts = ResponseContracts::new(ContractType::Github)
831            .add_body(200, Some("[]"), None)
832            .add_contract(422, "merge_request_conflict.json", None);
833        let (_, github) = setup_client!(contracts, default_github(), dyn MergeRequest);
834        let result = github.open(mr_args);
835        match result {
836            Ok(_) => panic!("Expected error"),
837            Err(err) => match err.downcast_ref::<error::GRError>() {
838                Some(error::GRError::RemoteUnexpectedResponseContract(_)) => (),
839                _ => panic!("Expected error::GRError::RemoteUnexpectedResponseContract"),
840            },
841        }
842    }
843
844    #[test]
845    fn test_open_merge_request_cannot_get_owner_org_namespace_in_existing_pull_request() {
846        let mr_args = MergeRequestBodyArgs::builder()
847            .source_branch("feature".to_string())
848            .build()
849            .unwrap();
850        let contracts = ResponseContracts::new(ContractType::Github)
851            .add_body(200, Some("[]"), None)
852            .add_contract(422, "merge_request_conflict.json", None);
853        // missing the repo name on path
854        let client_type = ClientType::Github(
855            Domain("github.com".to_string()),
856            BasePath("jordilin".to_string()),
857        );
858        let (_, github) = setup_client!(contracts, client_type, dyn MergeRequest);
859
860        let result = github.open(mr_args);
861        match result {
862            Ok(_) => panic!("Expected error"),
863            Err(err) => match err.downcast_ref::<error::GRError>() {
864                Some(error::GRError::ApplicationError(_)) => (),
865                _ => panic!("Expected error::GRError::ApplicationError"),
866            },
867        }
868    }
869
870    #[test]
871    fn test_merge_request_num_pages() {
872        let link_header = r#"<https://api.github.com/repos/jordilin/githapi/pulls?state=open&page=2>; rel="next", <https://api.github.com/repos/jordilin/githapi/pulls?state=open&page=2>; rel="last""#;
873        let mut headers = Headers::new();
874        headers.set("link".to_string(), link_header.to_string());
875        let contracts = ResponseContracts::new(ContractType::Github).add_body::<String>(
876            200,
877            None,
878            Some(headers),
879        );
880        let (client, github) = setup_client!(contracts, default_github(), dyn MergeRequest);
881        let args = MergeRequestListBodyArgs::builder()
882            .state(MergeRequestState::Opened)
883            .list_args(None)
884            .assignee(None)
885            .build()
886            .unwrap();
887        assert_eq!(Some(2), github.num_pages(args).unwrap());
888        assert_eq!(
889            "https://api.github.com/repos/jordilin/githapi/pulls?state=open&page=1",
890            *client.url(),
891        );
892        assert_eq!(
893            Some(ApiOperation::MergeRequest),
894            *client.api_operation.borrow()
895        );
896    }
897
898    #[test]
899    fn test_list_merge_requests_from_to_page_set_in_url() {
900        let contracts =
901            ResponseContracts::new(ContractType::Github).add_body(200, Some("[]"), None);
902
903        let (client, github) = setup_client!(contracts, default_github(), dyn MergeRequest);
904        let args = MergeRequestListBodyArgs::builder()
905            .state(MergeRequestState::Opened)
906            .list_args(Some(
907                ListBodyArgs::builder()
908                    .page(2)
909                    .max_pages(3)
910                    .build()
911                    .unwrap(),
912            ))
913            .assignee(None)
914            .build()
915            .unwrap();
916        github.list(args).unwrap();
917        assert_eq!(
918            "https://api.github.com/repos/jordilin/githapi/pulls?state=open&page=2",
919            *client.url(),
920        );
921        assert_eq!(
922            Some(ApiOperation::MergeRequest),
923            *client.api_operation.borrow()
924        );
925    }
926
927    #[test]
928    fn test_get_pull_requests_for_auth_user_is_assignee() {
929        let contracts = ResponseContracts::new(ContractType::Github).add_contract(
930            200,
931            "list_issues_user.json",
932            None,
933        );
934        let (client, github) = setup_client!(contracts, default_github(), dyn MergeRequest);
935        let args = MergeRequestListBodyArgs::builder()
936            .state(MergeRequestState::Opened)
937            .list_args(None)
938            .assignee(Some(
939                Member::builder()
940                    .name("tom".to_string())
941                    .username("tsawyer".to_string())
942                    .id(123456)
943                    .build()
944                    .unwrap(),
945            ))
946            .build()
947            .unwrap();
948        let merge_requests = github.list(args).unwrap();
949        assert_eq!(
950            "https://api.github.com/issues?state=open&filter=assigned",
951            *client.url()
952        );
953        assert!(merge_requests.len() == 1);
954        assert_eq!(
955            Some(ApiOperation::MergeRequest),
956            *client.api_operation.borrow()
957        );
958    }
959
960    #[test]
961    fn test_get_pull_requests_for_auth_user_is_author() {
962        let contracts = ResponseContracts::new(ContractType::Github).add_contract(
963            200,
964            "list_issues_user.json",
965            None,
966        );
967        let (client, github) = setup_client!(contracts, default_github(), dyn MergeRequest);
968        let args = MergeRequestListBodyArgs::builder()
969            .state(MergeRequestState::Opened)
970            .list_args(None)
971            .author(Some(
972                Member::builder()
973                    .name("tom".to_string())
974                    .username("tsawyer".to_string())
975                    .id(12345)
976                    .build()
977                    .unwrap(),
978            ))
979            .build()
980            .unwrap();
981        let merge_requests = github.list(args).unwrap();
982        assert_eq!(
983            "https://api.github.com/issues?state=open&filter=created",
984            *client.url()
985        );
986        assert!(merge_requests.len() == 1);
987        assert_eq!(
988            Some(ApiOperation::MergeRequest),
989            *client.api_operation.borrow()
990        );
991    }
992
993    #[test]
994    fn test_create_merge_request_comment() {
995        let contracts =
996            ResponseContracts::new(ContractType::Github).add_body::<String>(201, None, None);
997        let (client, github) = setup_client!(contracts, default_github(), dyn CommentMergeRequest);
998        let args = CommentMergeRequestBodyArgs::builder()
999            .id(23)
1000            .comment("Looks good to me".to_string())
1001            .build()
1002            .unwrap();
1003        github.create(args).unwrap();
1004        assert_eq!(
1005            "https://api.github.com/repos/jordilin/githapi/issues/23/comments",
1006            *client.url(),
1007        );
1008        assert_eq!(
1009            Some(ApiOperation::MergeRequest),
1010            *client.api_operation.borrow()
1011        );
1012    }
1013
1014    #[test]
1015    fn test_create_merge_request_comment_error_status_code() {
1016        let contracts =
1017            ResponseContracts::new(ContractType::Github).add_body::<String>(500, None, None);
1018        let (_, github) = setup_client!(contracts, default_github(), dyn CommentMergeRequest);
1019        let args = CommentMergeRequestBodyArgs::builder()
1020            .id(23)
1021            .comment("Looks good to me".to_string())
1022            .build()
1023            .unwrap();
1024        assert!(github.create(args).is_err());
1025    }
1026
1027    #[test]
1028    fn test_close_pull_request_ok() {
1029        let contracts = ResponseContracts::new(ContractType::Github).add_contract(
1030            200,
1031            "merge_request.json",
1032            None,
1033        );
1034        let (client, github) = setup_client!(contracts, default_github(), dyn MergeRequest);
1035        github.close(23).unwrap();
1036        assert_eq!(
1037            "https://api.github.com/repos/jordilin/githapi/pulls/23",
1038            *client.url(),
1039        );
1040        let actual_method = client.http_method.borrow();
1041        assert_eq!(http::Method::PATCH, actual_method[0]);
1042        assert_eq!(
1043            Some(ApiOperation::MergeRequest),
1044            *client.api_operation.borrow()
1045        );
1046    }
1047
1048    #[test]
1049    fn test_get_pull_request_details() {
1050        let contracts = ResponseContracts::new(ContractType::Github).add_contract(
1051            200,
1052            "merge_request.json",
1053            None,
1054        );
1055        let (client, github) = setup_client!(contracts, default_github(), dyn MergeRequest);
1056        github.get(23).unwrap();
1057        assert_eq!(
1058            "https://api.github.com/repos/jordilin/githapi/pulls/23",
1059            *client.url(),
1060        );
1061        assert_eq!(
1062            Some(ApiOperation::MergeRequest),
1063            *client.api_operation.borrow()
1064        );
1065    }
1066
1067    #[test]
1068    fn test_github_merge_pull_request() {
1069        let contracts = ResponseContracts::new(ContractType::Github).add_contract(
1070            200,
1071            "merge_request.json",
1072            None,
1073        );
1074        let (client, github) = setup_client!(contracts, default_github(), dyn MergeRequest);
1075        github.merge(23).unwrap();
1076        assert_eq!(
1077            "https://api.github.com/repos/jordilin/githapi/pulls/23/merge",
1078            *client.url(),
1079        );
1080        let actual_method = client.http_method.borrow();
1081        assert_eq!(http::Method::PUT, actual_method[0]);
1082        assert_eq!(
1083            Some(ApiOperation::MergeRequest),
1084            *client.api_operation.borrow()
1085        );
1086    }
1087
1088    #[test]
1089    fn test_list_pull_request_comments() {
1090        let contracts = ResponseContracts::new(ContractType::Github).add_body(
1091            200,
1092            Some(format!(
1093                "[{}]",
1094                get_contract(ContractType::Github, "comment.json")
1095            )),
1096            None,
1097        );
1098        let (client, github) = setup_client!(contracts, default_github(), dyn CommentMergeRequest);
1099        let args = CommentMergeRequestListBodyArgs::builder()
1100            .id(23)
1101            .list_args(None)
1102            .build()
1103            .unwrap();
1104        github.list(args).unwrap();
1105        assert_eq!(
1106            "https://api.github.com/repos/jordilin/githapi/issues/23/comments",
1107            *client.url(),
1108        );
1109        assert_eq!(
1110            Some(ApiOperation::MergeRequest),
1111            *client.api_operation.borrow()
1112        );
1113    }
1114
1115    #[test]
1116    fn test_pull_request_comment_num_pages() {
1117        let link_header = r#"<https://api.github.com/repos/jordilin/githapi/issues/23/comments?page=2>; rel="next", <https://api.github.com/repos/jordilin/githapi/issues/23/comments?page=2>; rel="last""#;
1118        let mut headers = Headers::new();
1119        headers.set("link".to_string(), link_header.to_string());
1120        let contracts = ResponseContracts::new(ContractType::Github).add_body::<String>(
1121            200,
1122            None,
1123            Some(headers),
1124        );
1125        let (client, github) = setup_client!(contracts, default_github(), dyn CommentMergeRequest);
1126        let args = CommentMergeRequestListBodyArgs::builder()
1127            .id(23)
1128            .list_args(None)
1129            .build()
1130            .unwrap();
1131        assert_eq!(Some(2), github.num_pages(args).unwrap());
1132        assert_eq!(
1133            "https://api.github.com/repos/jordilin/githapi/issues/23/comments?page=1",
1134            *client.url(),
1135        );
1136        assert_eq!(
1137            Some(ApiOperation::MergeRequest),
1138            *client.api_operation.borrow()
1139        );
1140    }
1141}