lib/github: handles issues
[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 isa Repo
44 # assert repo.name == "nit"
45 #
46 # var user = api.load_user("Morriar")
47 # assert user isa User
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 user.load_from_github
153 if was_error then return null
154 return user
155 end
156
157 # Get the Github repo with `full_name`.
158 #
159 # Returns `null` if the repo cannot be found.
160 #
161 # var api = new GithubAPI(get_github_oauth)
162 # var repo = api.load_repo("privat/nit")
163 # assert repo.name == "nit"
164 # assert repo.owner.login == "privat"
165 # assert repo.default_branch.name == "master"
166 fun load_repo(full_name: String): nullable Repo do
167 var repo = new Repo(self, full_name)
168 repo.load_from_github
169 if was_error then return null
170 return repo
171 end
172
173 # Get the Github branch with `name`.
174 #
175 # Returns `null` if the branch cannot be found.
176 #
177 # var api = new GithubAPI(get_github_oauth)
178 # var repo = api.load_repo("privat/nit")
179 # assert repo isa Repo
180 # var branch = api.load_branch(repo, "master")
181 # assert branch.name == "master"
182 # assert branch.commit isa Commit
183 fun load_branch(repo: Repo, name: String): nullable Branch do
184 var branch = new Branch(self, repo, name)
185 branch.load_from_github
186 if was_error then return null
187 return branch
188 end
189
190 # Get the Github commit with `sha`.
191 #
192 # Returns `null` if the commit cannot be found.
193 #
194 # var api = new GithubAPI(get_github_oauth)
195 # var repo = api.load_repo("privat/nit")
196 # assert repo isa Repo
197 # var commit = api.load_commit(repo, "64ce1f")
198 # assert commit isa Commit
199 fun load_commit(repo: Repo, sha: String): nullable Commit do
200 var commit = new Commit(self, repo, sha)
201 commit.load_from_github
202 if was_error then return null
203 return commit
204 end
205
206 # Get the Github issue #`number`.
207 #
208 # Returns `null` if the issue cannot be found.
209 #
210 # var api = new GithubAPI(get_github_oauth)
211 # var repo = api.load_repo("privat/nit")
212 # assert repo != null
213 # var issue = api.load_issue(repo, 1)
214 # assert issue.title == "Doc"
215 fun load_issue(repo: Repo, number: Int): nullable Issue do
216 var issue = new Issue(self, repo, number)
217 issue.load_from_github
218 if was_error then return null
219 return issue
220 end
221
222 # Get the Github label with `name`.
223 #
224 # Returns `null` if the label cannot be found.
225 #
226 # var api = new GithubAPI(get_github_oauth)
227 # var repo = api.load_repo("privat/nit")
228 # assert repo isa Repo
229 # var labl = api.load_label(repo, "ok_will_merge")
230 # assert labl != null
231 fun load_label(repo: Repo, name: String): nullable Label do
232 var labl = new Label(self, repo, name)
233 labl.load_from_github
234 if was_error then return null
235 return labl
236 end
237
238 # Get the Github milestone with `id`.
239 #
240 # Returns `null` if the milestone cannot be found.
241 #
242 # var api = new GithubAPI(get_github_oauth)
243 # var repo = api.load_repo("privat/nit")
244 # assert repo isa Repo
245 # var stone = api.load_milestone(repo, 4)
246 # assert stone.title == "v1.0prealpha"
247 fun load_milestone(repo: Repo, id: Int): nullable Milestone do
248 var milestone = new Milestone(self, repo, id)
249 milestone.load_from_github
250 if was_error then return null
251 return milestone
252 end
253 end
254
255 # Something returned by the Github API.
256 #
257 # Mainly a Nit wrapper around a JSON objet.
258 abstract class GithubEntity
259
260 # Github API instance.
261 var api: GithubAPI
262
263 # FIXME constructor should be private
264
265 # Key used to access this entity from Github api base.
266 fun key: String is abstract
267
268 # JSON representation of `self`.
269 #
270 # This is the same json structure than used by Github API.
271 var json: JsonObject is noinit, protected writable
272
273 # Load `json` from Github API.
274 private fun load_from_github do
275 json = api.load_from_github(key)
276 end
277
278 redef fun to_s do return json.to_json
279 end
280
281 # A Github user.
282 #
283 # Should be accessed from `GithubAPI::load_user`.
284 #
285 # See <https://developer.github.com/v3/users/>.
286 class User
287 super GithubEntity
288
289 redef var key is lazy do return "users/{login}"
290
291 # Github login.
292 var login: String
293
294 # Init `self` from a `json` object.
295 init from_json(api: GithubAPI, json: JsonObject) do
296 init(api, json["login"].to_s)
297 self.json = json
298 end
299
300 # Github User page url.
301 fun html_url: String do return json["html_url"].to_s
302
303 # Avatar image url for this user.
304 fun avatar_url: String do return json["avatar_url"].to_s
305 end
306
307 # A Github repository.
308 #
309 # Should be accessed from `GithubAPI::load_repo`.
310 #
311 # See <https://developer.github.com/v3/repos/>.
312 class Repo
313 super GithubEntity
314
315 redef var key is lazy do return "repos/{full_name}"
316
317 # Repo full name on Github.
318 var full_name: String
319
320 # Init `self` from a `json` object.
321 init from_json(api: GithubAPI, json: JsonObject) do
322 init(api, json["full_name"].to_s)
323 self.json = json
324 end
325
326 # Repo short name on Github.
327 fun name: String do return json["name"].to_s
328
329 # Github User page url.
330 fun html_url: String do return json["html_url"].to_s
331
332 # Get the repo owner.
333 fun owner: User do
334 return new User.from_json(api, json["owner"].as(JsonObject))
335 end
336
337 # List of branches associated with their names.
338 fun branches: Map[String, Branch] do
339 api.message(1, "Get branches for {full_name}")
340 var array = api.get("repos/{full_name}/branches")
341 var res = new HashMap[String, Branch]
342 if not array isa JsonArray then return res
343 for obj in array do
344 if not obj isa JsonObject then continue
345 var name = obj["name"].to_s
346 res[name] = new Branch.from_json(api, self, obj)
347 end
348 return res
349 end
350
351 # List of issues associated with their ids.
352 fun issues: Map[Int, Issue] do
353 api.message(1, "Get issues for {full_name}")
354 var res = new HashMap[Int, Issue]
355 var issue = last_issue
356 if issue == null then return res
357 res[issue.number] = issue
358 while issue.number > 1 do
359 issue = api.load_issue(self, issue.number - 1)
360 assert issue isa Issue
361 res[issue.number] = issue
362 end
363 return res
364 end
365
366 # Get the last published issue.
367 fun last_issue: nullable Issue do
368 var array = api.get("repos/{full_name}/issues")
369 if not array isa JsonArray then return null
370 if array.is_empty then return null
371 var obj = array.first
372 if not obj isa JsonObject then return null
373 return new Issue.from_json(api, self, obj)
374 end
375
376 # List of labels associated with their names.
377 fun labels: Map[String, Label] do
378 api.message(1, "Get labels for {full_name}")
379 var array = api.get("repos/{full_name}/labels")
380 var res = new HashMap[String, Label]
381 if not array isa JsonArray then return res
382 for obj in array do
383 if not obj isa JsonObject then continue
384 var name = obj["name"].to_s
385 res[name] = new Label.from_json(api, self, obj)
386 end
387 return res
388 end
389
390 # List of milestones associated with their ids.
391 fun milestones: Map[Int, Milestone] do
392 api.message(1, "Get milestones for {full_name}")
393 var array = api.get("repos/{full_name}/milestones")
394 var res = new HashMap[Int, Milestone]
395 if array isa JsonArray then
396 for obj in array do
397 if not obj isa JsonObject then continue
398 var number = obj["number"].as(Int)
399 res[number] = new Milestone.from_json(api, self, obj)
400 end
401 end
402 return res
403 end
404
405 # Repo default branch.
406 fun default_branch: Branch do
407 var name = json["default_branch"].to_s
408 var branch = api.load_branch(self, name)
409 assert branch isa Branch
410 return branch
411 end
412 end
413
414 # A `RepoEntity` is something contained in a `Repo`.
415 abstract class RepoEntity
416 super GithubEntity
417
418 # Repo that contains `self`.
419 var repo: Repo
420
421 # Init `self` from a `json` object.
422 init from_json(api: GithubAPI, repo: Repo, json: JsonObject) do
423 self.api = api
424 self.repo = repo
425 self.json = json
426 end
427 end
428
429 # A Github branch.
430 #
431 # Should be accessed from `GithubAPI::load_branch`.
432 #
433 # See <https://developer.github.com/v3/repos/#list-branches>.
434 class Branch
435 super RepoEntity
436
437 redef var key is lazy do return "{repo.key}/branches/{name}"
438
439 # Branch name.
440 var name: String
441
442 redef init from_json(api, repo, json) do
443 self.name = json["name"].to_s
444 super
445 end
446
447 # Get the last commit of `self`.
448 fun commit: Commit do
449 return new Commit.from_json(api, repo, json["commit"].as(JsonObject))
450 end
451
452 # List all commits in `self`.
453 #
454 # This can be long depending on the branch size.
455 # Commit are returned in an unspecified order.
456 fun commits: Array[Commit] do
457 var res = new Array[Commit]
458 var done = new HashSet[String]
459 var todos = new Array[Commit]
460 todos.add commit
461 while not todos.is_empty do
462 var commit = todos.pop
463 if done.has(commit.sha) then continue
464 done.add commit.sha
465 res.add commit
466 for parent in commit.parents do
467 todos.add parent
468 end
469 end
470 return res
471 end
472 end
473
474 # A Github commit.
475 #
476 # Should be accessed from `GithubAPI::load_commit`.
477 #
478 # See <https://developer.github.com/v3/commits/>.
479 class Commit
480 super RepoEntity
481
482 redef var key is lazy do return "{repo.key}/commits/{sha}"
483
484 # Commit SHA.
485 var sha: String
486
487 redef init from_json(api, repo, json) do
488 self.sha = json["sha"].to_s
489 super
490 end
491
492 # Parent commits of `self`.
493 fun parents: Array[Commit] do
494 var res = new Array[Commit]
495 var parents = json["parents"]
496 if not parents isa JsonArray then return res
497 for obj in parents do
498 if not obj isa JsonObject then continue
499 res.add(api.load_commit(repo, obj["sha"].to_s).as(not null))
500 end
501 return res
502 end
503
504 # Author of the commit.
505 fun author: nullable User do
506 if not json.has_key("author") then return null
507 var user = json["author"]
508 if not user isa JsonObject then return null
509 return new User.from_json(api, user)
510 end
511
512 # Committer of the commit.
513 fun committer: nullable User do
514 if not json.has_key("committer") then return null
515 var user = json["author"]
516 if not user isa JsonObject then return null
517 return new User.from_json(api, user)
518 end
519
520 # Authoring date as ISODate.
521 fun author_date: ISODate do
522 var commit = json["commit"].as(JsonObject)
523 var author = commit["author"].as(JsonObject)
524 return new ISODate.from_string(author["date"].to_s)
525 end
526
527 # Commit date as ISODate.
528 fun commit_date: ISODate do
529 var commit = json["commit"].as(JsonObject)
530 var author = commit["committer"].as(JsonObject)
531 return new ISODate.from_string(author["date"].to_s)
532 end
533
534 # Commit message.
535 fun message: String do return json["commit"].as(JsonObject)["message"].to_s
536 end
537
538 # A Github issue.
539 #
540 # Should be accessed from `GithubAPI::load_issue`.
541 #
542 # See <https://developer.github.com/v3/issues/>.
543 class Issue
544 super RepoEntity
545
546 redef var key is lazy do return "{repo.key}/issues/{number}"
547
548 # Issue Github ID.
549 var number: Int
550
551 redef init from_json(api, repo, json) do
552 self.number = json["number"].as(Int)
553 super
554 end
555
556 # Issue title.
557 fun title: String do return json["title"].to_s
558
559 # User that created this issue.
560 fun user: User do
561 return new User.from_json(api, json["user"].as(JsonObject))
562 end
563
564 # List of labels on this issue associated to their names.
565 fun labels: Map[String, Label] do
566 var res = new HashMap[String, Label]
567 for obj in json["labels"].as(JsonArray) do
568 if not obj isa JsonObject then continue
569 var name = obj["name"].to_s
570 res[name] = new Label.from_json(api, repo, obj)
571 end
572 return res
573 end
574
575 # State of the issue on Github.
576 fun state: String do return json["state"].to_s
577
578 # Is the issue locked?
579 fun locked: Bool do return json["locked"].as(Bool)
580
581 # Assigned `User` (if any).
582 fun assignee: nullable User do
583 var assignee = json["assignee"]
584 if not assignee isa JsonObject then return null
585 return new User.from_json(api, assignee)
586 end
587
588 # `Milestone` (if any).
589 fun milestone: nullable Milestone do
590 var milestone = json["milestone"]
591 if not milestone isa JsonObject then return null
592 return new Milestone.from_json(api, repo, milestone)
593 end
594
595 # Number of comments on this issue.
596 fun comments_count: Int do return json["comments"].to_s.to_i
597
598 # Creation time in ISODate format.
599 fun created_at: ISODate do
600 return new ISODate.from_string(json["created_at"].to_s)
601 end
602
603 # Last update time in ISODate format (if any).
604 fun updated_at: nullable ISODate do
605 var res = json["updated_at"]
606 if res == null then return null
607 return new ISODate.from_string(res.to_s)
608 end
609
610 # Close time in ISODate format (if any).
611 fun closed_at: nullable ISODate do
612 var res = json["closed_at"]
613 if res == null then return null
614 return new ISODate.from_string(res.to_s)
615 end
616
617 # Full description of the issue.
618 fun body: String do return json["body"].to_s
619
620 # User that closed this issue (if any).
621 fun closed_by: nullable User do
622 var closer = json["closed_by"]
623 if not closer isa JsonObject then return null
624 return new User.from_json(api, closer)
625 end
626 end
627 # A Github label.
628 #
629 # Should be accessed from `GithubAPI::load_label`.
630 #
631 # See <https://developer.github.com/v3/issues/labels/>.
632 class Label
633 super RepoEntity
634
635 redef var key is lazy do return "{repo.key}/labels/{name}"
636
637 # Label name.
638 var name: String
639
640 redef init from_json(api, repo, json) do
641 self.name = json["name"].to_s
642 super
643 end
644
645 # Label color code.
646 fun color: String do return json["color"].to_s
647 end
648
649 # A Github milestone.
650 #
651 # Should be accessed from `GithubAPI::load_milestone`.
652 #
653 # See <https://developer.github.com/v3/issues/milestones/>.
654 class Milestone
655 super RepoEntity
656
657 redef var key is lazy do return "{repo.key}/milestones/{number}"
658
659 # The milestone id on Github.
660 var number: Int
661
662 redef init from_json(api, repo, json) do
663 super
664 self.number = json["number"].as(Int)
665 end
666
667 # Milestone title.
668 fun title: String do return json["title"].to_s
669
670 # Milestone long description.
671 fun description: String do return json["description"].to_s
672
673 # Count of opened issues linked to this milestone.
674 fun open_issues: Int do return json["open_issues"].to_s.to_i
675
676 # Count of closed issues linked to this milestone.
677 fun closed_issues: Int do return json["closed_issues"].to_s.to_i
678
679 # Milestone state.
680 fun state: String do return json["state"].to_s
681
682 # Creation time in ISODate format.
683 fun created_at: ISODate do
684 return new ISODate.from_string(json["created_at"].to_s)
685 end
686
687 # User that created this milestone.
688 fun creator: User do
689 return new User.from_json(api, json["creator"].as(JsonObject))
690 end
691
692 # Due time in ISODate format (if any).
693 fun due_on: nullable ISODate do
694 var res = json["updated_at"]
695 if res == null then return null
696 return new ISODate.from_string(res.to_s)
697 end
698
699 # Update time in ISODate format (if any).
700 fun updated_at: nullable ISODate do
701 var res = json["updated_at"]
702 if res == null then return null
703 return new ISODate.from_string(res.to_s)
704 end
705
706 # Close time in ISODate format (if any).
707 fun closed_at: nullable ISODate do
708 var res = json["closed_at"]
709 if res == null then return null
710 return new ISODate.from_string(res.to_s)
711 end
712 end