c77c0d0eae67924b67c26978db900b7ae10d1c50
[nit.git] / share / nitdoc / js / plugins / github.js
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 Documentation generator for the nit language.
16 Generate API documentation in HTML format from nit source code.
17 */
18
19 /*
20 * Nitdoc.Github comment edition module
21 *
22 * Allows user to modify source code comments directly from the Nitdoc
23 */
24 define([
25 "jquery",
26 "github-api",
27 "highlight",
28 "marked",
29 "nit",
30 "plugins/modalbox",
31 "plugins/github/loginbox",
32 "plugins/github/commentbox",
33 "utils"
34 ], function($, GithubAPI, hljs, marked) {
35 var GithubUser = function(login, password, repo, branch) {
36 this.login = login;
37 this.password = password;
38 this.repo = repo;
39 this.auth = "Basic " + (login + ':' + password).base64Encode();
40 this.branch = branch;
41 }
42
43 var GithubUI = {
44 init: function(upstream, basesha1) {
45 console.info("Github plugin: init GitHub module (upstream: "+ upstream +", base: " + basesha1 + ")");
46 this.origin = this._parseUpstream(upstream);
47 this._initMarked();
48 // Add github menu
49 $("#topmenu>.container-fluid").append(
50 $("<a/>")
51 .attr({
52 "id": "nitdoc-github-li",
53 "type": "button",
54 "class": "navbar-btn navbar-right btn-link",
55 "href": "#",
56 "data-container": "body",
57 "data-toggle": "popover",
58 "data-placement": "bottom",
59 "data-content": "bottom",
60 "data-html": "true",
61 })
62 .loginbox()
63 //.loginbox("displayLogin")
64 .bind("loginbox_logoff", function() {
65 GithubUI.disactivate();
66 })
67 .bind("loginbox_login", function(event, infos) {
68 GithubUI._tryLoginFromCredentials(infos);
69 })
70 );
71 // check local session
72 this._tryLoginFromLocalSession();
73 },
74
75 activate: function(user, origin) {
76 this.openedComments = 0;
77 this._saveSession(user);
78 $("#nitdoc-github-li").loginbox("displayLogout", origin, user);
79 this._attachCommentBoxes();
80 this._reloadComments();
81
82 // Prevent page unload if there is comments in editing mode
83 $(window).on('beforeunload', function() {
84 if(GithubUI.openedComments > 0){
85 return "There is uncommited modified comments. Are you sure you want to leave this page?";
86 }
87 });
88 },
89
90 disactivate: function() {
91 if(this.openedComments > 0){
92 if(!confirm('There is uncommited modified comments. Are you sure you want to leave this page?')) {
93 return false;
94 }
95 }
96
97 localStorage.clear();
98 $("#nitdoc-github-li").loginbox("toggle");
99 $("#nitdoc-github-li").loginbox("displayLogin");
100 $(window).unbind('beforeunload');
101 //window.location.reload();
102 },
103
104 /* login */
105
106 _checkLoginInfos: function(infos) {
107 if(!infos.login || !infos.password || !infos.repo || !infos.branch) {
108 $("<p/>")
109 .text("Please enter your GitHub username, password, repository and branch.")
110 .modalbox({
111 title: "Sign in error",
112 isError: true
113 })
114 .modalbox("open");
115 return false;
116 } else {
117 return true;
118 }
119 },
120
121 _tryLoginFromCredentials: function(infos) {
122 if(this._checkLoginInfos(infos)) {
123 var isok = this._tryLogin(infos.login, infos.password, infos.repo, infos.branch);
124 if(isok === true) {
125 this.activate(this.user, this.origin);
126 } else {
127 if(isok == "error:login") {
128 $("<p/>")
129 .text("The username, password, repo or branch you entered is incorrect.")
130 .modalbox({
131 title: "Github sign in error",
132 isError: true
133 })
134 .modalbox("open");
135 } else if(isok == "error:sha") {
136 $("<p/>")
137 .text("The provided Github repository must contain the base commit '" + this.origin.sha + "'.")
138 .modalbox({
139 title: "Github base commit error",
140 isError: true
141 })
142 .modalbox("open");
143 } else if(isok == "error:profile") {
144 $("<p/>")
145 .text("Please set your public name and email in your " +
146 "<a href='https://github.com/settings/profile'>GitHub profile</a>." +
147 "<br/><br/>Your public profile informations are used to sign-off your commits.")
148 .modalbox({
149 title: "Github profile error",
150 isError: true
151 })
152 .modalbox("open");
153 }
154 }
155 }
156 },
157
158 _tryLoginFromLocalSession: function() {
159 if(localStorage.user) {
160 var session = JSON.parse(localStorage.user);
161 var isok = this._tryLogin(
162 session.login,
163 session.password.base64Decode(),
164 session.repo,
165 session.branch
166 );
167 if(isok === true) {
168 this.activate(this.user, this.origin);
169 } else {
170 console.debug("Github plugin: Session found but authentification failed");
171 localStorage.clear();
172 }
173 } else {
174 console.debug("Github plugin: No session found");
175 }
176 },
177
178 _tryLogin: function(login, password, repo, branch) {
179 var tmpUser = new GithubUser(login, password, repo, branch);
180 if(!GithubAPI.login(tmpUser)) {
181 return "error:login";
182 }
183 if(!tmpUser.infos.name || !tmpUser.infos.email) {
184 return "error:profile";
185 }
186 var commit = GithubAPI.getCommit(tmpUser, this.origin.sha);
187 if(!commit || !commit.sha) {
188 return "error:sha";
189 }
190 this.user = tmpUser;
191 return true;
192 },
193
194 _saveSession: function(user) {
195 localStorage.user = JSON.stringify({
196 login: user.login,
197 password: user.password.base64Encode(),
198 repo: user.repo,
199 branch: user.branch,
200 });
201 // check local storage synchro with branch
202 if(localStorage.base != this.origin.sha) {
203 console.log("Base changed: cleaned cache");
204 localStorage.requests = "[]";
205 localStorage.base = this.origin.sha;
206 }
207 },
208
209 /* html decoration */
210
211 // Attach edit button on each comment
212 _attachCommentBoxes: function() {
213 $("textarea.baseComment").each(function() {
214 $(this).commentbox();
215
216 var isNew = false;
217 if(!$(this).val()) {
218 isNew = true;
219 $(this).nextAll(".info:first").find(".noComment").hide()
220 $(this).nextAll(".info:first").before(
221 $("<div/>")
222 .hide()
223 .addClass("comment")
224 .append(
225 $("<div/>").addClass("nitdoc")
226 )
227 )
228 }
229
230 $(this).nextAll(".info:first").prepend(
231 $("<a/>")
232 .addClass("nitdoc-github-editComment")
233 .css("cursor", "pointer")
234 .text((isNew ? "add" : "edit") + " comment")
235 .click($.proxy(GithubUI._openCommentBox, GithubUI, null, $(this)))
236 .after(" for ")
237 )
238
239 $(this).bind("commentbox_commit", function(event, data) {
240 GithubUI._saveChanges(data);
241 $(this).commentbox("close");
242 GithubUI._reloadComments();
243 })
244 .bind("commentbox_preview", function(event, data) {
245 $("<div/>")
246 .append($("<h4/>").text("Comment:"))
247 .append(
248 $("<div/>")
249 .addClass("description")
250 .append(
251 $("<div/>")
252 .addClass("comment")
253 .append(
254 $("<div/>")
255 .addClass("nitdoc")
256 .html(marked(data.value))
257 )
258 )
259 )
260 .append($("<h4/>").text("Message:"))
261 .append(
262 $("<div/>")
263 .addClass("description")
264 .append(
265 $("<div/>")
266 .addClass("comment")
267 .append(
268 $("<div/>").html(marked(data.message))
269 )
270 )
271 )
272 .modalbox({
273 title: "Preview comment",
274 css: {"min-width": "500px"}
275 })
276 .modalbox("open");
277 })
278 .bind("commentbox_open", function(event, data) {
279 GithubUI.openedComments++;
280 $(this).nextAll(".comment").hide();
281 })
282 .bind("commentbox_close", function(event, data) {
283 GithubUI.openedComments--;
284 $(this).nextAll(".comment").show();
285 });
286 });
287 },
288
289 // reload comments from saved pull request
290 _reloadComments: function() {
291 if(!localStorage.requests){ return; }
292 $("p.pullRequest").remove();
293 var requests = JSON.parse(localStorage.requests);
294 // Look for modified comments in page
295 for(i in requests) {
296 if(!requests[i]) { continue; }
297 var request = requests[i];
298 $("textarea[data-comment-location=\"" + request.location + "\"]").each(function () {
299 if(request.isClosed) {
300 var oldComment = request.oldComment.base64Decode();
301 var htmlComment = marked(oldComment);
302 $(this).val(oldComment);
303 if(!$(this).val()) {
304 $(this).nextAll("div.comment:first").hide();
305 } else {
306 $(this).nextAll("div.comment:first").show();
307 }
308 $(this).nextAll("div.comment").find("div.nitdoc").empty().html(htmlComment);
309 $(this).nextAll("p.info").find("a.nitdoc-github-editComment").show();
310 } else {
311 var newComment = request.comment.base64Decode();
312 var htmlComment = marked(newComment);
313 $(this).val(newComment);
314 if(!$(this).val()) {
315 $(this).nextAll("div.comment:first").hide();
316 } else {
317 $(this).nextAll("div.comment:first").show();
318 }
319 $(this).nextAll("div.comment").find("div.nitdoc").empty().html(htmlComment);
320 GithubUI._addPullRequestLink($(this), request);
321 $(this).nextAll("p.info").find("a.nitdoc-github-editComment").hide();
322 }
323 });
324 }
325 },
326
327 _addPullRequestLink: function(baseArea, request) {
328 baseArea.nextAll("p.info").before(
329 $("<p/>")
330 .addClass("pullRequest inheritance")
331 .text("comment modified in ")
332 .append(
333 $("<a/>")
334 .attr({
335 href: request.request.html_url,
336 title: "Review on GitHub"
337 })
338 .text("pull request #" + request.request.number)
339 )
340 .append(" ")
341 .append(
342 $("<a/>")
343 .data("pullrequest-number", request.request.number)
344 .addClass("nitdoc-github-update")
345 .text("update")
346 .click($.proxy(GithubUI._doUpdateRequest, GithubUI, null, baseArea, request))
347 )
348 .append(" ")
349 .append(
350 $("<a/>")
351 .data("pullrequest-number", request.request.number)
352 .addClass("nitdoc-github-cancel")
353 .text("cancel")
354 .click($.proxy(GithubUI._doCancelRequest, GithubUI, null, baseArea, request))
355 )
356 );
357 },
358
359 /* github calls */
360
361 _saveChanges: function(edit) {
362 // if pull request update close existing pull request for the comment
363 if(edit.requestID) {
364 this._closePullRequest(edit.requestID);
365 }
366 edit.oldContent = this._getFileContent(edit.location.path);
367 edit.newContent = this._mergeComment(edit.oldContent, edit.newComment, edit.location);
368 edit.request = this._pushChanges(edit)
369 if(!edit.request) {
370 $("<p/>")
371 .text("Unable to commit changes.<br/>" + response)
372 .modalbox({
373 title: "Github commit error",
374 isError: true
375 })
376 .modalbox("open");
377 return;
378 }
379 this._saveRequest(edit);
380 },
381
382 // save pull request in local storage
383 _saveRequest: function(edit) {
384 var requests = {};
385 if(localStorage.requests) {requests = JSON.parse(localStorage.requests)}
386 requests[edit.request.number] = {
387 request: edit.request,
388 location: edit.location.origin,
389 comment: edit.newComment.base64Encode(),
390 oldComment: edit.oldComment.base64Encode()
391 };
392 localStorage.requests = JSON.stringify(requests);
393 },
394
395 /*
396 Creating a new pull request with the new comment take 5 steps:
397 1. get the base tree from latest commit
398
399 2. create a new blob with updated file content
400 3. post a new tree from base tree and blob
401 4. post the new commit with new tree
402 5. create the pull request
403 */
404 _pushChanges: function(edit) {
405 var baseTree = GithubAPI.getTree(this.user, this.origin.sha);
406 if(!baseTree.sha) {
407 $("<p/>")
408 .text("Unable to locate base tree.<br/>" + baseTree.status + ": " + baseTree.statusText)
409 .modalbox({
410 title: "Github commit error",
411 isError: true
412 })
413 .modalbox("open");
414 return false;
415 }
416 console.log("Base tree: " + baseTree.url);
417 var newBlob = GithubAPI.createBlob(this.user, edit.newContent);
418 if(!newBlob.sha) {
419 $("<p/>")
420 .text("Unable to create new blob.<br/>" + newBlob.status + ": " + newBlob.statusText)
421 .modalbox({
422 title: "Github commit error",
423 isError: true
424 })
425 .modalbox("open");
426 return false;
427 }
428 console.log("New blob: " + newBlob.url);
429 var newTree = GithubAPI.createTree(this.user, baseTree, edit.location.path, newBlob);
430 if(!newTree.sha) {
431 $("<p/>")
432 .text("Unable to create new tree.<br/>" + newTree.status + ": " + newTree.statusText)
433 .modalbox({
434 title: "Github commit error",
435 isError: true
436 })
437 .modalbox("open");
438 return false;
439 }
440 console.log("New tree: " + newTree.url);
441 var newCommit = GithubAPI.createCommit(this.user, edit.message, baseTree.sha, newTree);
442 if(!newCommit.sha) {
443 $("<p/>")
444 .text("Unable to create new commit.<br/>" + newCommit.status + ": " + newCommit.statusText)
445 .modalbox({
446 title: "Github commit error",
447 isError: true
448 })
449 .modalbox("open");
450 return false;
451 }
452 console.log("New commit: " + newCommit.url);
453 var pullRequest = GithubAPI.createPullRequest(this.user, edit.title, "Pull request from Nitdoc", this.origin, newCommit.sha);
454 if(!pullRequest.number) {
455 $("<p/>")
456 .text("Unable to create pull request.<br/>" + pullRequest.status + ": " + pullRequest.statusText)
457 .modalbox({
458 title: "Github commit error",
459 isError: true
460 })
461 .modalbox("open");
462 return false;
463 }
464 console.log("New pull request: " + pullRequest.url);
465 return pullRequest;
466 },
467
468 // close previously opened pull request
469 _closePullRequest: function(number) {
470 var requests = JSON.parse(localStorage.requests);
471 if(!requests[number]) {
472 $("<p/>")
473 .text("Unable to close pull request.<br/>" + "Pull request " + number + "not found")
474 .modalbox({
475 title: "Github commit error",
476 isError: true
477 })
478 .modalbox("open");
479 return false;
480 }
481 // close pull request
482 var res = GithubAPI.updatePullRequest(this.user, "Closed from Nitdoc", "", "closed", requests[number].request);
483 if(!res.id) {
484 $("<p/>")
485 .text("Unable to close pull request.<br/>" + res.status + ": " + res.statusText)
486 .modalbox({
487 title: "Github commit error",
488 isError: true
489 })
490 .modalbox("open");
491 return false;
492 }
493 // update in localstorage
494 requests[number].isClosed = true;
495 localStorage.requests = JSON.stringify(requests);
496 },
497
498 /* internals */
499
500 _initMarked: function() {
501 var renderer = new marked.Renderer();
502 renderer.code = function(code) {
503 return '<pre class="nitcode hljs">' + hljs.highlight('nit', code).value + '</pre>';
504 }
505 renderer.codespan = function(code) {
506 return '<code class="nitcode hljs">' + hljs.highlight('nit', code).value + '</code>';
507 }
508 marked.setOptions({
509 renderer: renderer,
510 gfm: true,
511 tables: true,
512 breaks: true,
513 pedantic: false,
514 sanitize: true,
515 smartLists: true,
516 smartypants: false
517 });
518 },
519
520 _parseUpstream: function(upstream) {
521 var parts = upstream.split(":");
522 return {
523 user: parts[0],
524 repo: parts[1],
525 branch: parts[2],
526 sha: basesha1
527 };
528 },
529
530 _getFileContent: function(githubUrl) {
531 var origFile = GithubAPI.getFile(this.user, githubUrl);
532 if(!origFile.content) {
533 $("<p/>")
534 .text("Unable to locate source file.<br/>" + origFile.status + ": " + origFile.statusText)
535 .modalbox({
536 title: "Github commit error",
537 isError: true
538 })
539 .modalbox("open");
540 return;
541 }
542 var base64Content = origFile.content.substring(0, origFile.content.length - 1)
543 return base64Content.base64Decode();
544 },
545
546 _mergeComment: function(fileContent, comment, location) {
547 // replace comment in file content
548 var res = new String();
549 var lines = fileContent.split("\n");
550 // copy lines fron 0 to lstart
551 for(var i = 0; i < location.lstart - 1; i++) {
552 res += lines[i] + "\n";
553 }
554 // set comment
555 if(comment && comment != "") {
556 var commentLines = comment.split("\n");
557 for(var i = 0; i < commentLines.length; i++) {
558 var line = commentLines[i];
559 var tab = location.tabpos > 1 ? "\t" : "";
560 res += tab + (line.length > 0 ? "# " : "#") + line + "\n";
561 }
562 }
563 // copy lines fron lend to end
564 for(var i = location.lend - 1; i < lines.length; i++) {
565 res += lines[i];
566 if(i < lines.length - 1) { res += "\n"; }
567 }
568 return res;
569 },
570
571 /* events */
572
573 _openCommentBox: function(event, baseArea) {
574 baseArea.commentbox("open", this.user);
575 },
576
577 _doCancelRequest: function(event, baseArea, request) {
578 this._closePullRequest(request.request.number);
579 this._reloadComments();
580 },
581
582 _doUpdateRequest: function(event, baseArea, request) {
583 baseArea.commentbox("open", this.user, request.request.number);
584 },
585 }
586
587 // Get github plugin data
588 var upstream = $("body").attr("data-github-upstream");
589 var basesha1 = $("body").attr("data-github-base-sha1");
590 if(upstream && basesha1) {
591 GithubUI.init(upstream, basesha1);
592 }
593 });