lib/github: factorize load_from_github behavior.
[nit.git] / lib / github / api.nit
1 # This file is part of NIT ( http://www.nitlanguage.org ).
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 # Nit object oriented interface to Github api.
16 #
17 # This modules reifies Github API elements as Nit classes.
18 #
19 # For most use-cases you need to use the `GithubAPI` client.
20 module api
21
22 import github_curl
23
24 # Interface to Github REST API.
25 #
26 # Used by all `GithubEntity` to perform requests.
27 #
28 # Usage:
29 #
30 # ~~~
31 # # Get Github authentification token.
32 # var token = get_github_oauth
33 # assert not token.is_empty
34 #
35 # # Init the client.
36 # var api = new GithubAPI(token)
37 # ~~~
38 #
39 # The API client allows to get Github API entities:
40 #
41 # ~~~
42 # var repo = api.load_repo("privat/nit")
43 # assert repo != null
44 # assert repo.name == "nit"
45 #
46 # var user = api.load_user("Morriar")
47 # assert user != null
48 # assert user.login == "Morriar"
49 # ~~~
50 class GithubAPI
51
52 # Github API OAuth token.
53 #
54 # This token is used to authenticate the application on Github API.
55 # Be aware that there is [rate limits](https://developer.github.com/v3/rate_limit/)
56 # associated to the key.
57 var auth: String
58
59 # Github API base url.
60 #
61 # Default is `https://api.github.com` and should not be changed.
62 var api_url = "https://api.github.com"
63
64 # User agent used for HTTP requests.
65 #
66 # Default is `nit_github_api`.
67 #
68 # See <https://developer.github.com/v3/#user-agent-required>
69 var user_agent = "nit_github_api"
70
71 # Curl instance.
72 #
73 # Internal Curl instance used to perform API calls.
74 private var ghcurl: GithubCurl is noinit
75
76 # Verbosity level.
77 #
78 # * `0`: only errors (default)
79 # * `1`: verbose
80 var verbose_lvl = 0 is public writable
81
82 init do
83 ghcurl = new GithubCurl(auth, user_agent)
84 end
85
86 # Execute a GET request on Github API.
87 #
88 # This method returns raw json data.
89 # See other `load_*` methods to use more expressive types.
90 #
91 # var api = new GithubAPI(get_github_oauth)
92 # var obj = api.get("repos/privat/nit")
93 # assert obj isa JsonObject
94 # assert obj["name"] == "nit"
95 #
96 # Returns `null` in case of `error`.
97 #
98 # obj = api.get("foo/bar/baz")
99 # assert obj == null
100 # assert api.was_error
101 # var err = api.last_error
102 # assert err isa GithubError
103 # assert err.name == "GithubAPIError"
104 # assert err.message == "Not Found"
105 fun get(path: String): nullable Jsonable do
106 path = sanitize_uri(path)
107 var res = ghcurl.get_and_parse("{api_url}/{path}")
108 if res isa Error then
109 last_error = res
110 was_error = true
111 return null
112 end
113 was_error = false
114 return res
115 end
116
117 # Display a message depending on `verbose_lvl`.
118 fun message(lvl: Int, message: String) do
119 if lvl <= verbose_lvl then print message
120 end
121
122 # Escape `uri` in an acceptable format for Github.
123 private fun sanitize_uri(uri: String): String do
124 # TODO better URI escape.
125 return uri.replace(" ", "%20")
126 end
127
128 # Last error occured during Github API communications.
129 var last_error: nullable Error = null is public writable
130
131 # Does the last request provoqued an error?
132 var was_error = false is protected writable
133
134 # Load the json object from Github.
135 # See `GithubEntity::load_from_github`.
136 private fun load_from_github(key: String): JsonObject do
137 message(1, "Get {key} (github)")
138 var res = get(key)
139 if was_error then return new JsonObject
140 return res.as(JsonObject)
141 end
142
143 # Get the Github user with `login`.
144 #
145 # Returns `null` if the user cannot be found.
146 #
147 # var api = new GithubAPI(get_github_oauth)
148 # var user = api.load_user("Morriar")
149 # assert user.login == "Morriar"
150 fun load_user(login: String): nullable User do
151 var user = new User(self, login)
152 return user.load_from_github
153 end
154
155 # Get the Github repo with `full_name`.
156 #
157 # Returns `null` if the repo cannot be found.
158 #
159 # var api = new GithubAPI(get_github_oauth)
160 # var repo = api.load_repo("privat/nit")
161 # assert repo.name == "nit"
162 # assert repo.owner.login == "privat"
163 # assert repo.default_branch.name == "master"
164 fun load_repo(full_name: String): nullable Repo do
165 var repo = new Repo(self, full_name)
166 return repo.load_from_github
167 end
168
169 # Get the Github branch with `name`.
170 #
171 # Returns `null` if the branch cannot be found.
172 #
173 # var api = new GithubAPI(get_github_oauth)
174 # var repo = api.load_repo("privat/nit")
175 # assert repo != null
176 # var branch = api.load_branch(repo, "master")
177 # assert branch.name == "master"
178 # assert branch.commit isa Commit
179 fun load_branch(repo: Repo, name: String): nullable Branch do
180 var branch = new Branch(self, repo, name)
181 return branch.load_from_github
182 end
183
184 # Get the Github commit with `sha`.
185 #
186 # Returns `null` if the commit cannot be found.
187 #
188 # var api = new GithubAPI(get_github_oauth)
189 # var repo = api.load_repo("privat/nit")
190 # assert repo != null
191 # var commit = api.load_commit(repo, "64ce1f")
192 # assert commit isa Commit
193 fun load_commit(repo: Repo, sha: String): nullable Commit do
194 var commit = new Commit(self, repo, sha)
195 return commit.load_from_github
196 end
197
198 # Get the Github issue #`number`.
199 #
200 # Returns `null` if the issue cannot be found.
201 #
202 # var api = new GithubAPI(get_github_oauth)
203 # var repo = api.load_repo("privat/nit")
204 # assert repo != null
205 # var issue = api.load_issue(repo, 1)
206 # assert issue.title == "Doc"
207 fun load_issue(repo: Repo, number: Int): nullable Issue do
208 var issue = new Issue(self, repo, number)
209 return issue.load_from_github
210 end
211
212 # Get the Github pull request #`number`.
213 #
214 # Returns `null` if the pull request cannot be found.
215 #
216 # var api = new GithubAPI(get_github_oauth)
217 # var repo = api.load_repo("privat/nit")
218 # assert repo != null
219 # var pull = api.load_pull(repo, 1)
220 # assert pull.title == "Doc"
221 # assert pull.user.login == "Morriar"
222 fun load_pull(repo: Repo, number: Int): nullable PullRequest do
223 var pull = new PullRequest(self, repo, number)
224 return pull.load_from_github
225 end
226
227 # Get the Github label with `name`.
228 #
229 # Returns `null` if the label cannot be found.
230 #
231 # var api = new GithubAPI(get_github_oauth)
232 # var repo = api.load_repo("privat/nit")
233 # assert repo != null
234 # var labl = api.load_label(repo, "ok_will_merge")
235 # assert labl != null
236 fun load_label(repo: Repo, name: String): nullable Label do
237 var labl = new Label(self, repo, name)
238 return labl.load_from_github
239 end
240
241 # Get the Github milestone with `id`.
242 #
243 # Returns `null` if the milestone cannot be found.
244 #
245 # var api = new GithubAPI(get_github_oauth)
246 # var repo = api.load_repo("privat/nit")
247 # assert repo != null
248 # var stone = api.load_milestone(repo, 4)
249 # assert stone.title == "v1.0prealpha"
250 fun load_milestone(repo: Repo, id: Int): nullable Milestone do
251 var milestone = new Milestone(self, repo, id)
252 return milestone.load_from_github
253 end
254 end
255
256 # Something returned by the Github API.
257 #
258 # Mainly a Nit wrapper around a JSON objet.
259 abstract class GithubEntity
260
261 # Github API instance.
262 var api: GithubAPI
263
264 # FIXME constructor should be private
265
266 # Key used to access this entity from Github api base.
267 fun key: String is abstract
268
269 # JSON representation of `self`.
270 #
271 # This is the same json structure than used by Github API.
272 var json: JsonObject is noinit, protected writable
273
274 # Load `json` from Github API.
275 private fun load_from_github: nullable SELF do
276 json = api.load_from_github(key)
277 if api.was_error then return null
278 return self
279 end
280
281 redef fun to_s do return json.to_json
282 end
283
284 # A Github user.
285 #
286 # Should be accessed from `GithubAPI::load_user`.
287 #
288 # See <https://developer.github.com/v3/users/>.
289 class User
290 super GithubEntity
291
292 redef var key is lazy do return "users/{login}"
293
294 # Github login.
295 var login: String
296
297 # Init `self` from a `json` object.
298 init from_json(api: GithubAPI, json: JsonObject) do
299 init(api, json["login"].to_s)
300 self.json = json
301 end
302
303 # Github User page url.
304 fun html_url: String do return json["html_url"].to_s
305
306 # Avatar image url for this user.
307 fun avatar_url: String do return json["avatar_url"].to_s
308 end
309
310 # A Github repository.
311 #
312 # Should be accessed from `GithubAPI::load_repo`.
313 #
314 # See <https://developer.github.com/v3/repos/>.
315 class Repo
316 super GithubEntity
317
318 redef var key is lazy do return "repos/{full_name}"
319
320 # Repo full name on Github.
321 var full_name: String
322
323 # Init `self` from a `json` object.
324 init from_json(api: GithubAPI, json: JsonObject) do
325 init(api, json["full_name"].to_s)
326 self.json = json
327 end
328
329 # Repo short name on Github.
330 fun name: String do return json["name"].to_s
331
332 # Github User page url.
333 fun html_url: String do return json["html_url"].to_s
334
335 # Get the repo owner.
336 fun owner: User do
337 return new User.from_json(api, json["owner"].as(JsonObject))
338 end
339
340 # List of branches associated with their names.
341 fun branches: Map[String, Branch] do
342 api.message(1, "Get branches for {full_name}")
343 var array = api.get("repos/{full_name}/branches")
344 var res = new HashMap[String, Branch]
345 if not array isa JsonArray then return res
346 for obj in array do
347 if not obj isa JsonObject then continue
348 var name = obj["name"].to_s
349 res[name] = new Branch.from_json(api, self, obj)
350 end
351 return res
352 end
353
354 # List of issues associated with their ids.
355 fun issues: Map[Int, Issue] do
356 api.message(1, "Get issues for {full_name}")
357 var res = new HashMap[Int, Issue]
358 var issue = last_issue
359 if issue == null then return res
360 res[issue.number] = issue
361 while issue.number > 1 do
362 issue = api.load_issue(self, issue.number - 1)
363 assert issue isa Issue
364 res[issue.number] = issue
365 end
366 return res
367 end
368
369 # Get the last published issue.
370 fun last_issue: nullable Issue do
371 var array = api.get("repos/{full_name}/issues")
372 if not array isa JsonArray then return null
373 if array.is_empty then return null
374 var obj = array.first
375 if not obj isa JsonObject then return null
376 return new Issue.from_json(api, self, obj)
377 end
378
379 # List of labels associated with their names.
380 fun labels: Map[String, Label] do
381 api.message(1, "Get labels for {full_name}")
382 var array = api.get("repos/{full_name}/labels")
383 var res = new HashMap[String, Label]
384 if not array isa JsonArray then return res
385 for obj in array do
386 if not obj isa JsonObject then continue
387 var name = obj["name"].to_s
388 res[name] = new Label.from_json(api, self, obj)
389 end
390 return res
391 end
392
393 # List of milestones associated with their ids.
394 fun milestones: Map[Int, Milestone] do
395 api.message(1, "Get milestones for {full_name}")
396 var array = api.get("repos/{full_name}/milestones")
397 var res = new HashMap[Int, Milestone]
398 if array isa JsonArray then
399 for obj in array do
400 if not obj isa JsonObject then continue
401 var number = obj["number"].as(Int)
402 res[number] = new Milestone.from_json(api, self, obj)
403 end
404 end
405 return res
406 end
407
408 # List of pull-requests associated with their ids.
409 #
410 # Implementation notes: because PR numbers are not consecutive,
411 # PR are loaded from pages.
412 # See: https://developer.github.com/v3/pulls/#list-pull-requests
413 fun pulls: Map[Int, PullRequest] do
414 api.message(1, "Get pulls for {full_name}")
415 var res = new HashMap[Int, PullRequest]
416 var page = 1
417 var array = api.get("{key}/pulls?page={page}").as(JsonArray)
418 while not array.is_empty do
419 for obj in array do
420 if not obj isa JsonObject then continue
421 var number = obj["number"].as(Int)
422 res[number] = new PullRequest.from_json(api, self, obj)
423 end
424 page += 1
425 array = api.get("{key}/pulls?page={page}").as(JsonArray)
426 end
427 return res
428 end
429
430 # Repo default branch.
431 fun default_branch: Branch do
432 var name = json["default_branch"].to_s
433 var branch = api.load_branch(self, name)
434 assert branch isa Branch
435 return branch
436 end
437 end
438
439 # A `RepoEntity` is something contained in a `Repo`.
440 abstract class RepoEntity
441 super GithubEntity
442
443 # Repo that contains `self`.
444 var repo: Repo
445
446 # Init `self` from a `json` object.
447 init from_json(api: GithubAPI, repo: Repo, json: JsonObject) do
448 self.api = api
449 self.repo = repo
450 self.json = json
451 end
452 end
453
454 # A Github branch.
455 #
456 # Should be accessed from `GithubAPI::load_branch`.
457 #
458 # See <https://developer.github.com/v3/repos/#list-branches>.
459 class Branch
460 super RepoEntity
461
462 redef var key is lazy do return "{repo.key}/branches/{name}"
463
464 # Branch name.
465 var name: String
466
467 redef init from_json(api, repo, json) do
468 self.name = json["name"].to_s
469 super
470 end
471
472 # Get the last commit of `self`.
473 fun commit: Commit do
474 return new Commit.from_json(api, repo, json["commit"].as(JsonObject))
475 end
476
477 # List all commits in `self`.
478 #
479 # This can be long depending on the branch size.
480 # Commit are returned in an unspecified order.
481 fun commits: Array[Commit] do
482 var res = new Array[Commit]
483 var done = new HashSet[String]
484 var todos = new Array[Commit]
485 todos.add commit
486 while not todos.is_empty do
487 var commit = todos.pop
488 if done.has(commit.sha) then continue
489 done.add commit.sha
490 res.add commit
491 for parent in commit.parents do
492 todos.add parent
493 end
494 end
495 return res
496 end
497 end
498
499 # A Github commit.
500 #
501 # Should be accessed from `GithubAPI::load_commit`.
502 #
503 # See <https://developer.github.com/v3/commits/>.
504 class Commit
505 super RepoEntity
506
507 redef var key is lazy do return "{repo.key}/commits/{sha}"
508
509 # Commit SHA.
510 var sha: String
511
512 redef init from_json(api, repo, json) do
513 self.sha = json["sha"].to_s
514 super
515 end
516
517 # Parent commits of `self`.
518 fun parents: Array[Commit] do
519 var res = new Array[Commit]
520 var parents = json["parents"]
521 if not parents isa JsonArray then return res
522 for obj in parents do
523 if not obj isa JsonObject then continue
524 res.add(api.load_commit(repo, obj["sha"].to_s).as(not null))
525 end
526 return res
527 end
528
529 # Author of the commit.
530 fun author: nullable User do
531 if not json.has_key("author") then return null
532 var user = json["author"]
533 if not user isa JsonObject then return null
534 return new User.from_json(api, user)
535 end
536
537 # Committer of the commit.
538 fun committer: nullable User do
539 if not json.has_key("committer") then return null
540 var user = json["author"]
541 if not user isa JsonObject then return null
542 return new User.from_json(api, user)
543 end
544
545 # Authoring date as ISODate.
546 fun author_date: ISODate do
547 var commit = json["commit"].as(JsonObject)
548 var author = commit["author"].as(JsonObject)
549 return new ISODate.from_string(author["date"].to_s)
550 end
551
552 # Commit date as ISODate.
553 fun commit_date: ISODate do
554 var commit = json["commit"].as(JsonObject)
555 var author = commit["committer"].as(JsonObject)
556 return new ISODate.from_string(author["date"].to_s)
557 end
558
559 # Commit message.
560 fun message: String do return json["commit"].as(JsonObject)["message"].to_s
561 end
562
563 # A Github issue.
564 #
565 # Should be accessed from `GithubAPI::load_issue`.
566 #
567 # See <https://developer.github.com/v3/issues/>.
568 class Issue
569 super RepoEntity
570
571 redef var key is lazy do return "{repo.key}/issues/{number}"
572
573 # Issue Github ID.
574 var number: Int
575
576 redef init from_json(api, repo, json) do
577 self.number = json["number"].as(Int)
578 super
579 end
580
581 # Issue title.
582 fun title: String do return json["title"].to_s
583
584 # User that created this issue.
585 fun user: User do
586 return new User.from_json(api, json["user"].as(JsonObject))
587 end
588
589 # List of labels on this issue associated to their names.
590 fun labels: Map[String, Label] do
591 var res = new HashMap[String, Label]
592 for obj in json["labels"].as(JsonArray) do
593 if not obj isa JsonObject then continue
594 var name = obj["name"].to_s
595 res[name] = new Label.from_json(api, repo, obj)
596 end
597 return res
598 end
599
600 # State of the issue on Github.
601 fun state: String do return json["state"].to_s
602
603 # Is the issue locked?
604 fun locked: Bool do return json["locked"].as(Bool)
605
606 # Assigned `User` (if any).
607 fun assignee: nullable User do
608 var assignee = json["assignee"]
609 if not assignee isa JsonObject then return null
610 return new User.from_json(api, assignee)
611 end
612
613 # `Milestone` (if any).
614 fun milestone: nullable Milestone do
615 var milestone = json["milestone"]
616 if not milestone isa JsonObject then return null
617 return new Milestone.from_json(api, repo, milestone)
618 end
619
620 # Number of comments on this issue.
621 fun comments_count: Int do return json["comments"].to_s.to_i
622
623 # Creation time in ISODate format.
624 fun created_at: ISODate do
625 return new ISODate.from_string(json["created_at"].to_s)
626 end
627
628 # Last update time in ISODate format (if any).
629 fun updated_at: nullable ISODate do
630 var res = json["updated_at"]
631 if res == null then return null
632 return new ISODate.from_string(res.to_s)
633 end
634
635 # Close time in ISODate format (if any).
636 fun closed_at: nullable ISODate do
637 var res = json["closed_at"]
638 if res == null then return null
639 return new ISODate.from_string(res.to_s)
640 end
641
642 # TODO link to pull request
643
644 # Full description of the issue.
645 fun body: String do return json["body"].to_s
646
647 # User that closed this issue (if any).
648 fun closed_by: nullable User do
649 var closer = json["closed_by"]
650 if not closer isa JsonObject then return null
651 return new User.from_json(api, closer)
652 end
653 end
654
655 # A Github pull request.
656 #
657 # Should be accessed from `GithubAPI::load_pull`.
658 #
659 # PullRequest are basically Issues with more data.
660 # See <https://developer.github.com/v3/pulls/>.
661 class PullRequest
662 super Issue
663
664 redef var key is lazy do return "{repo.key}/pulls/{number}"
665
666 # Merge time in ISODate format (if any).
667 fun merged_at: nullable ISODate do
668 var res = json["merged_at"]
669 if res == null then return null
670 return new ISODate.from_string(res.to_s)
671 end
672
673 # Merge commit SHA.
674 fun merge_commit_sha: String do return json["merge_commit_sha"].to_s
675
676 # Count of comments made on the pull request diff.
677 fun review_comments: Int do return json["review_comments"].to_s.to_i
678
679 # Pull request head (can be a commit SHA or a branch name).
680 fun head: PullRef do
681 var json = json["head"].as(JsonObject)
682 return new PullRef(api, json)
683 end
684
685 # Pull request base (can be a commit SHA or a branch name).
686 fun base: PullRef do
687 var json = json["base"].as(JsonObject)
688 return new PullRef(api, json)
689 end
690
691 # Is this pull request merged?
692 fun merged: Bool do return json["merged"].as(Bool)
693
694 # Is this pull request mergeable?
695 fun mergeable: Bool do return json["mergeable"].as(Bool)
696
697 # Mergeable state of this pull request.
698 #
699 # See <https://developer.github.com/v3/pulls/#list-pull-requests>.
700 fun mergeable_state: Int do return json["mergeable_state"].to_s.to_i
701
702 # User that merged this pull request (if any).
703 fun merged_by: nullable User do
704 var merger = json["merged_by"]
705 if not merger isa JsonObject then return null
706 return new User.from_json(api, merger)
707 end
708
709 # Count of commits in this pull request.
710 fun commits: Int do return json["commits"].to_s.to_i
711
712 # Added line count.
713 fun additions: Int do return json["additions"].to_s.to_i
714
715 # Deleted line count.
716 fun deletions: Int do return json["deletions"].to_s.to_i
717
718 # Changed files count.
719 fun changed_files: Int do return json["changed_files"].to_s.to_i
720 end
721
722 # A pull request reference (used for head and base).
723 class PullRef
724
725 # Api instance that maintains self.
726 var api: GithubAPI
727
728 # JSON representation.
729 var json: JsonObject
730
731 # Label pointed by `self`.
732 fun labl: String do return json["label"].to_s
733
734 # Reference pointed by `self`.
735 fun ref: String do return json["ref"].to_s
736
737 # Commit SHA pointed by `self`.
738 fun sha: String do return json["sha"].to_s
739
740 # User pointed by `self`.
741 fun user: User do
742 return new User.from_json(api, json["user"].as(JsonObject))
743 end
744
745 # Repo pointed by `self`.
746 fun repo: Repo do
747 return new Repo.from_json(api, json["repo"].as(JsonObject))
748 end
749 end
750
751 # A Github label.
752 #
753 # Should be accessed from `GithubAPI::load_label`.
754 #
755 # See <https://developer.github.com/v3/issues/labels/>.
756 class Label
757 super RepoEntity
758
759 redef var key is lazy do return "{repo.key}/labels/{name}"
760
761 # Label name.
762 var name: String
763
764 redef init from_json(api, repo, json) do
765 self.name = json["name"].to_s
766 super
767 end
768
769 # Label color code.
770 fun color: String do return json["color"].to_s
771 end
772
773 # A Github milestone.
774 #
775 # Should be accessed from `GithubAPI::load_milestone`.
776 #
777 # See <https://developer.github.com/v3/issues/milestones/>.
778 class Milestone
779 super RepoEntity
780
781 redef var key is lazy do return "{repo.key}/milestones/{number}"
782
783 # The milestone id on Github.
784 var number: Int
785
786 redef init from_json(api, repo, json) do
787 super
788 self.number = json["number"].as(Int)
789 end
790
791 # Milestone title.
792 fun title: String do return json["title"].to_s
793
794 # Milestone long description.
795 fun description: String do return json["description"].to_s
796
797 # Count of opened issues linked to this milestone.
798 fun open_issues: Int do return json["open_issues"].to_s.to_i
799
800 # Count of closed issues linked to this milestone.
801 fun closed_issues: Int do return json["closed_issues"].to_s.to_i
802
803 # Milestone state.
804 fun state: String do return json["state"].to_s
805
806 # Creation time in ISODate format.
807 fun created_at: ISODate do
808 return new ISODate.from_string(json["created_at"].to_s)
809 end
810
811 # User that created this milestone.
812 fun creator: User do
813 return new User.from_json(api, json["creator"].as(JsonObject))
814 end
815
816 # Due time in ISODate format (if any).
817 fun due_on: nullable ISODate do
818 var res = json["updated_at"]
819 if res == null then return null
820 return new ISODate.from_string(res.to_s)
821 end
822
823 # Update time in ISODate format (if any).
824 fun updated_at: nullable ISODate do
825 var res = json["updated_at"]
826 if res == null then return null
827 return new ISODate.from_string(res.to_s)
828 end
829
830 # Close time in ISODate format (if any).
831 fun closed_at: nullable ISODate do
832 var res = json["closed_at"]
833 if res == null then return null
834 return new ISODate.from_string(res.to_s)
835 end
836 end