##// END OF EJS Templates
release: merge back stable branch into default
marcink -
r2571:4154ff95 merge default
parent child Browse files
Show More
@@ -0,0 +1,40 b''
1 |RCE| 4.11.3 |RNS|
2 ------------------
3
4 Release Date
5 ^^^^^^^^^^^^
6
7 - 2018-02-14
8
9
10 New Features
11 ^^^^^^^^^^^^
12
13
14
15 General
16 ^^^^^^^
17
18
19
20 Security
21 ^^^^^^^^
22
23
24
25 Performance
26 ^^^^^^^^^^^
27
28
29
30 Fixes
31 ^^^^^
32
33 - Pull requests: fixed problems with opening pull requests when default branches
34 in repository didn't exist or were closed.
35
36
37 Upgrade notes
38 ^^^^^^^^^^^^^
39
40 - Unscheduled bugfix release fixing pull request opening issues reported.
@@ -1,33 +1,34 b''
1 1 1bd3e92b7e2e2d2024152b34bb88dff1db544a71 v4.0.0
2 2 170c5398320ea6cddd50955e88d408794c21d43a v4.0.1
3 3 c3fe200198f5aa34cf2e4066df2881a9cefe3704 v4.1.0
4 4 7fd5c850745e2ea821fb4406af5f4bff9b0a7526 v4.1.1
5 5 41c87da28a179953df86061d817bc35533c66dd2 v4.1.2
6 6 baaf9f5bcea3bae0ef12ae20c8b270482e62abb6 v4.2.0
7 7 32a70c7e56844a825f61df496ee5eaf8c3c4e189 v4.2.1
8 8 fa695cdb411d294679ac081d595ac654e5613b03 v4.3.0
9 9 0e4dc11b58cad833c513fe17bac39e6850edf959 v4.3.1
10 10 8a876f48f5cb1d018b837db28ff928500cb32cfb v4.4.0
11 11 8dd86b410b1aac086ffdfc524ef300f896af5047 v4.4.1
12 12 d2514226abc8d3b4f6fb57765f47d1b6fb360a05 v4.4.2
13 13 27d783325930af6dad2741476c0d0b1b7c8415c2 v4.5.0
14 14 7f2016f352abcbdba4a19d4039c386e9629449da v4.5.1
15 15 416fec799314c70a5c780fb28b3357b08869333a v4.5.2
16 16 27c3b85fafc83143e6678fbc3da69e1615bcac55 v4.6.0
17 17 5ad13deb9118c2a5243d4032d4d9cc174e5872db v4.6.1
18 18 2be921e01fa24bb102696ada596f87464c3666f6 v4.7.0
19 19 7198bdec29c2872c974431d55200d0398354cdb1 v4.7.1
20 20 bd1c8d230fe741c2dfd7100a0ef39fd0774fd581 v4.7.2
21 21 9731914f89765d9628dc4dddc84bc9402aa124c8 v4.8.0
22 22 c5a2b7d0e4bbdebc4a62d7b624befe375207b659 v4.9.0
23 23 d9aa3b27ac9f7e78359775c75fedf7bfece232f1 v4.9.1
24 24 4ba4d74981cec5d6b28b158f875a2540952c2f74 v4.10.0
25 25 0a6821cbd6b0b3c21503002f88800679fa35ab63 v4.10.1
26 26 434ad90ec8d621f4416074b84f6e9ce03964defb v4.10.2
27 27 68baee10e698da2724c6e0f698c03a6abb993bf2 v4.10.3
28 28 00821d3afd1dce3f4767cc353f84a17f7d5218a1 v4.10.4
29 29 22f6744ad8cc274311825f63f953e4dee2ea5cb9 v4.10.5
30 30 96eb24bea2f5f9258775245e3f09f6fa0a4dda01 v4.10.6
31 31 3121217a812c956d7dd5a5875821bd73e8002a32 v4.11.0
32 32 fa98b454715ac5b912f39e84af54345909a2a805 v4.11.1
33 33 3982abcfdcc229a723cebe52d3a9bcff10bba08e v4.11.2
34 33195f145db9172f0a8f1487e09207178a6ab065 v4.11.3
@@ -1,110 +1,111 b''
1 1 .. _rhodecode-release-notes-ref:
2 2
3 3 Release Notes
4 4 =============
5 5
6 6 |RCE| 4.x Versions
7 7 ------------------
8 8
9 9 .. toctree::
10 10 :maxdepth: 1
11 11
12 release-notes-4.11.3.rst
12 13 release-notes-4.11.2.rst
13 14 release-notes-4.11.1.rst
14 15 release-notes-4.11.0.rst
15 16 release-notes-4.10.6.rst
16 17 release-notes-4.10.5.rst
17 18 release-notes-4.10.4.rst
18 19 release-notes-4.10.3.rst
19 20 release-notes-4.10.2.rst
20 21 release-notes-4.10.1.rst
21 22 release-notes-4.10.0.rst
22 23 release-notes-4.9.1.rst
23 24 release-notes-4.9.0.rst
24 25 release-notes-4.8.0.rst
25 26 release-notes-4.7.2.rst
26 27 release-notes-4.7.1.rst
27 28 release-notes-4.7.0.rst
28 29 release-notes-4.6.1.rst
29 30 release-notes-4.6.0.rst
30 31 release-notes-4.5.2.rst
31 32 release-notes-4.5.1.rst
32 33 release-notes-4.5.0.rst
33 34 release-notes-4.4.2.rst
34 35 release-notes-4.4.1.rst
35 36 release-notes-4.4.0.rst
36 37 release-notes-4.3.1.rst
37 38 release-notes-4.3.0.rst
38 39 release-notes-4.2.1.rst
39 40 release-notes-4.2.0.rst
40 41 release-notes-4.1.2.rst
41 42 release-notes-4.1.1.rst
42 43 release-notes-4.1.0.rst
43 44 release-notes-4.0.1.rst
44 45 release-notes-4.0.0.rst
45 46
46 47 |RCE| 3.x Versions
47 48 ------------------
48 49
49 50 .. toctree::
50 51 :maxdepth: 1
51 52
52 53 release-notes-3.8.4.rst
53 54 release-notes-3.8.3.rst
54 55 release-notes-3.8.2.rst
55 56 release-notes-3.8.1.rst
56 57 release-notes-3.8.0.rst
57 58 release-notes-3.7.1.rst
58 59 release-notes-3.7.0.rst
59 60 release-notes-3.6.1.rst
60 61 release-notes-3.6.0.rst
61 62 release-notes-3.5.2.rst
62 63 release-notes-3.5.1.rst
63 64 release-notes-3.5.0.rst
64 65 release-notes-3.4.1.rst
65 66 release-notes-3.4.0.rst
66 67 release-notes-3.3.4.rst
67 68 release-notes-3.3.3.rst
68 69 release-notes-3.3.2.rst
69 70 release-notes-3.3.1.rst
70 71 release-notes-3.3.0.rst
71 72 release-notes-3.2.3.rst
72 73 release-notes-3.2.2.rst
73 74 release-notes-3.2.1.rst
74 75 release-notes-3.2.0.rst
75 76 release-notes-3.1.1.rst
76 77 release-notes-3.1.0.rst
77 78 release-notes-3.0.2.rst
78 79 release-notes-3.0.1.rst
79 80 release-notes-3.0.0.rst
80 81
81 82 |RCE| 2.x Versions
82 83 ------------------
83 84
84 85 .. toctree::
85 86 :maxdepth: 1
86 87
87 88 release-notes-2.2.8.rst
88 89 release-notes-2.2.7.rst
89 90 release-notes-2.2.6.rst
90 91 release-notes-2.2.5.rst
91 92 release-notes-2.2.4.rst
92 93 release-notes-2.2.3.rst
93 94 release-notes-2.2.2.rst
94 95 release-notes-2.2.1.rst
95 96 release-notes-2.2.0.rst
96 97 release-notes-2.1.0.rst
97 98 release-notes-2.0.2.rst
98 99 release-notes-2.0.1.rst
99 100 release-notes-2.0.0.rst
100 101
101 102 |RCE| 1.x Versions
102 103 ------------------
103 104
104 105 .. toctree::
105 106 :maxdepth: 1
106 107
107 108 release-notes-1.7.2.rst
108 109 release-notes-1.7.1.rst
109 110 release-notes-1.7.0.rst
110 111 release-notes-1.6.0.rst
@@ -1,587 +1,593 b''
1 1 // # Copyright (C) 2010-2018 RhodeCode GmbH
2 2 // #
3 3 // # This program is free software: you can redistribute it and/or modify
4 4 // # it under the terms of the GNU Affero General Public License, version 3
5 5 // # (only), as published by the Free Software Foundation.
6 6 // #
7 7 // # This program is distributed in the hope that it will be useful,
8 8 // # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 // # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 // # GNU General Public License for more details.
11 11 // #
12 12 // # You should have received a copy of the GNU Affero General Public License
13 13 // # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 // #
15 15 // # This program is dual-licensed. If you wish to learn more about the
16 16 // # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 // # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19
20 20 var prButtonLockChecks = {
21 21 'compare': false,
22 22 'reviewers': false
23 23 };
24 24
25 25 /**
26 26 * lock button until all checks and loads are made. E.g reviewer calculation
27 27 * should prevent from submitting a PR
28 28 * @param lockEnabled
29 29 * @param msg
30 30 * @param scope
31 31 */
32 32 var prButtonLock = function(lockEnabled, msg, scope) {
33 33 scope = scope || 'all';
34 34 if (scope == 'all'){
35 35 prButtonLockChecks['compare'] = !lockEnabled;
36 36 prButtonLockChecks['reviewers'] = !lockEnabled;
37 37 } else if (scope == 'compare') {
38 38 prButtonLockChecks['compare'] = !lockEnabled;
39 39 } else if (scope == 'reviewers'){
40 40 prButtonLockChecks['reviewers'] = !lockEnabled;
41 41 }
42 42 var checksMeet = prButtonLockChecks.compare && prButtonLockChecks.reviewers;
43 43 if (lockEnabled) {
44 44 $('#save').attr('disabled', 'disabled');
45 45 }
46 46 else if (checksMeet) {
47 47 $('#save').removeAttr('disabled');
48 48 }
49 49
50 50 if (msg) {
51 51 $('#pr_open_message').html(msg);
52 52 }
53 53 };
54 54
55 55
56 56 /**
57 57 Generate Title and Description for a PullRequest.
58 58 In case of 1 commits, the title and description is that one commit
59 59 in case of multiple commits, we iterate on them with max N number of commits,
60 60 and build description in a form
61 61 - commitN
62 62 - commitN+1
63 63 ...
64 64
65 65 Title is then constructed from branch names, or other references,
66 66 replacing '-' and '_' into spaces
67 67
68 68 * @param sourceRef
69 69 * @param elements
70 70 * @param limit
71 71 * @returns {*[]}
72 72 */
73 73 var getTitleAndDescription = function(sourceRef, elements, limit) {
74 74 var title = '';
75 75 var desc = '';
76 76
77 77 $.each($(elements).get().reverse().slice(0, limit), function(idx, value) {
78 78 var rawMessage = $(value).find('td.td-description .message').data('messageRaw');
79 79 desc += '- ' + rawMessage.split('\n')[0].replace(/\n+$/, "") + '\n';
80 80 });
81 81 // only 1 commit, use commit message as title
82 82 if (elements.length === 1) {
83 83 title = $(elements[0]).find('td.td-description .message').data('messageRaw').split('\n')[0];
84 84 }
85 85 else {
86 86 // use reference name
87 87 title = sourceRef.replace(/-/g, ' ').replace(/_/g, ' ').capitalizeFirstLetter();
88 88 }
89 89
90 90 return [title, desc]
91 91 };
92 92
93 93
94 94
95 95 ReviewersController = function () {
96 96 var self = this;
97 97 this.$reviewRulesContainer = $('#review_rules');
98 98 this.$rulesList = this.$reviewRulesContainer.find('.pr-reviewer-rules');
99 99 this.forbidReviewUsers = undefined;
100 100 this.$reviewMembers = $('#review_members');
101 101 this.currentRequest = null;
102 102
103 103 this.defaultForbidReviewUsers = function() {
104 104 return [
105 105 {'username': 'default',
106 106 'user_id': templateContext.default_user.user_id}
107 107 ];
108 108 };
109 109
110 110 this.hideReviewRules = function() {
111 111 self.$reviewRulesContainer.hide();
112 112 };
113 113
114 114 this.showReviewRules = function() {
115 115 self.$reviewRulesContainer.show();
116 116 };
117 117
118 118 this.addRule = function(ruleText) {
119 119 self.showReviewRules();
120 120 return '<div>- {0}</div>'.format(ruleText)
121 121 };
122 122
123 123 this.loadReviewRules = function(data) {
124 124 // reset forbidden Users
125 125 this.forbidReviewUsers = self.defaultForbidReviewUsers();
126 126
127 127 // reset state of review rules
128 128 self.$rulesList.html('');
129 129
130 130 if (!data || data.rules === undefined || $.isEmptyObject(data.rules)) {
131 131 // default rule, case for older repo that don't have any rules stored
132 132 self.$rulesList.append(
133 133 self.addRule(
134 134 _gettext('All reviewers must vote.'))
135 135 );
136 136 return self.forbidReviewUsers
137 137 }
138 138
139 139 if (data.rules.voting !== undefined) {
140 140 if (data.rules.voting < 0) {
141 141 self.$rulesList.append(
142 142 self.addRule(
143 143 _gettext('All individual reviewers must vote.'))
144 144 )
145 145 } else if (data.rules.voting === 1) {
146 146 self.$rulesList.append(
147 147 self.addRule(
148 148 _gettext('At least {0} reviewer must vote.').format(data.rules.voting))
149 149 )
150 150
151 151 } else {
152 152 self.$rulesList.append(
153 153 self.addRule(
154 154 _gettext('At least {0} reviewers must vote.').format(data.rules.voting))
155 155 )
156 156 }
157 157 }
158 158
159 159 if (data.rules.voting_groups !== undefined) {
160 160 $.each(data.rules.voting_groups, function(index, rule_data) {
161 161 self.$rulesList.append(
162 162 self.addRule(rule_data.text)
163 163 )
164 164 });
165 165 }
166 166
167 167 if (data.rules.use_code_authors_for_review) {
168 168 self.$rulesList.append(
169 169 self.addRule(
170 170 _gettext('Reviewers picked from source code changes.'))
171 171 )
172 172 }
173 173 if (data.rules.forbid_adding_reviewers) {
174 174 $('#add_reviewer_input').remove();
175 175 self.$rulesList.append(
176 176 self.addRule(
177 177 _gettext('Adding new reviewers is forbidden.'))
178 178 )
179 179 }
180 180 if (data.rules.forbid_author_to_review) {
181 181 self.forbidReviewUsers.push(data.rules_data.pr_author);
182 182 self.$rulesList.append(
183 183 self.addRule(
184 184 _gettext('Author is not allowed to be a reviewer.'))
185 185 )
186 186 }
187 187 if (data.rules.forbid_commit_author_to_review) {
188 188
189 189 if (data.rules_data.forbidden_users) {
190 190 $.each(data.rules_data.forbidden_users, function(index, member_data) {
191 191 self.forbidReviewUsers.push(member_data)
192 192 });
193 193
194 194 }
195 195
196 196 self.$rulesList.append(
197 197 self.addRule(
198 198 _gettext('Commit Authors are not allowed to be a reviewer.'))
199 199 )
200 200 }
201 201
202 202 return self.forbidReviewUsers
203 203 };
204 204
205 205 this.loadDefaultReviewers = function(sourceRepo, sourceRef, targetRepo, targetRef) {
206 206
207 207 if (self.currentRequest) {
208 208 // make sure we cleanup old running requests before triggering this
209 209 // again
210 210 self.currentRequest.abort();
211 211 }
212 212
213 213 $('.calculate-reviewers').show();
214 214 // reset reviewer members
215 215 self.$reviewMembers.empty();
216 216
217 217 prButtonLock(true, null, 'reviewers');
218 218 $('#user').hide(); // hide user autocomplete before load
219 219
220 if (sourceRef.length !== 3 || targetRef.length !== 3) {
221 // don't load defaults in case we're missing some refs...
222 $('.calculate-reviewers').hide();
223 return
224 }
225
220 226 var url = pyroutes.url('repo_default_reviewers_data',
221 227 {
222 228 'repo_name': templateContext.repo_name,
223 229 'source_repo': sourceRepo,
224 230 'source_ref': sourceRef[2],
225 231 'target_repo': targetRepo,
226 232 'target_ref': targetRef[2]
227 233 });
228 234
229 235 self.currentRequest = $.get(url)
230 236 .done(function(data) {
231 237 self.currentRequest = null;
232 238
233 239 // review rules
234 240 self.loadReviewRules(data);
235 241
236 242 for (var i = 0; i < data.reviewers.length; i++) {
237 243 var reviewer = data.reviewers[i];
238 244 self.addReviewMember(
239 245 reviewer, reviewer.reasons, reviewer.mandatory);
240 246 }
241 247 $('.calculate-reviewers').hide();
242 248 prButtonLock(false, null, 'reviewers');
243 249 $('#user').show(); // show user autocomplete after load
244 250 });
245 251 };
246 252
247 253 // check those, refactor
248 254 this.removeReviewMember = function(reviewer_id, mark_delete) {
249 255 var reviewer = $('#reviewer_{0}'.format(reviewer_id));
250 256
251 257 if(typeof(mark_delete) === undefined){
252 258 mark_delete = false;
253 259 }
254 260
255 261 if(mark_delete === true){
256 262 if (reviewer){
257 263 // now delete the input
258 264 $('#reviewer_{0} input'.format(reviewer_id)).remove();
259 265 // mark as to-delete
260 266 var obj = $('#reviewer_{0}_name'.format(reviewer_id));
261 267 obj.addClass('to-delete');
262 268 obj.css({"text-decoration":"line-through", "opacity": 0.5});
263 269 }
264 270 }
265 271 else{
266 272 $('#reviewer_{0}'.format(reviewer_id)).remove();
267 273 }
268 274 };
269 275 this.reviewMemberEntry = function() {
270 276
271 277 };
272 278 this.addReviewMember = function(reviewer_obj, reasons, mandatory) {
273 279 var members = self.$reviewMembers.get(0);
274 280 var id = reviewer_obj.user_id;
275 281 var username = reviewer_obj.username;
276 282
277 283 var reasons = reasons || [];
278 284 var mandatory = mandatory || false;
279 285
280 286 // register IDS to check if we don't have this ID already in
281 287 var currentIds = [];
282 288 var _els = self.$reviewMembers.find('li').toArray();
283 289 for (el in _els){
284 290 currentIds.push(_els[el].id)
285 291 }
286 292
287 293 var userAllowedReview = function(userId) {
288 294 var allowed = true;
289 295 $.each(self.forbidReviewUsers, function(index, member_data) {
290 296 if (parseInt(userId) === member_data['user_id']) {
291 297 allowed = false;
292 298 return false // breaks the loop
293 299 }
294 300 });
295 301 return allowed
296 302 };
297 303
298 304 var userAllowed = userAllowedReview(id);
299 305 if (!userAllowed){
300 306 alert(_gettext('User `{0}` not allowed to be a reviewer').format(username));
301 307 } else {
302 308 // only add if it's not there
303 309 var alreadyReviewer = currentIds.indexOf('reviewer_'+id) != -1;
304 310
305 311 if (alreadyReviewer) {
306 312 alert(_gettext('User `{0}` already in reviewers').format(username));
307 313 } else {
308 314 members.innerHTML += renderTemplate('reviewMemberEntry', {
309 315 'member': reviewer_obj,
310 316 'mandatory': mandatory,
311 317 'allowed_to_update': true,
312 318 'review_status': 'not_reviewed',
313 319 'review_status_label': _gettext('Not Reviewed'),
314 320 'reasons': reasons,
315 321 'create': true
316 322 });
317 323 }
318 324 }
319 325
320 326 };
321 327
322 328 this.updateReviewers = function(repo_name, pull_request_id){
323 329 var postData = $('#reviewers input').serialize();
324 330 _updatePullRequest(repo_name, pull_request_id, postData);
325 331 };
326 332
327 333 };
328 334
329 335
330 336 var _updatePullRequest = function(repo_name, pull_request_id, postData) {
331 337 var url = pyroutes.url(
332 338 'pullrequest_update',
333 339 {"repo_name": repo_name, "pull_request_id": pull_request_id});
334 340 if (typeof postData === 'string' ) {
335 341 postData += '&csrf_token=' + CSRF_TOKEN;
336 342 } else {
337 343 postData.csrf_token = CSRF_TOKEN;
338 344 }
339 345 var success = function(o) {
340 346 window.location.reload();
341 347 };
342 348 ajaxPOST(url, postData, success);
343 349 };
344 350
345 351 /**
346 352 * PULL REQUEST update commits
347 353 */
348 354 var updateCommits = function(repo_name, pull_request_id) {
349 355 var postData = {
350 356 'update_commits': true};
351 357 _updatePullRequest(repo_name, pull_request_id, postData);
352 358 };
353 359
354 360
355 361 /**
356 362 * PULL REQUEST edit info
357 363 */
358 364 var editPullRequest = function(repo_name, pull_request_id, title, description) {
359 365 var url = pyroutes.url(
360 366 'pullrequest_update',
361 367 {"repo_name": repo_name, "pull_request_id": pull_request_id});
362 368
363 369 var postData = {
364 370 'title': title,
365 371 'description': description,
366 372 'edit_pull_request': true,
367 373 'csrf_token': CSRF_TOKEN
368 374 };
369 375 var success = function(o) {
370 376 window.location.reload();
371 377 };
372 378 ajaxPOST(url, postData, success);
373 379 };
374 380
375 381 var initPullRequestsCodeMirror = function (textAreaId) {
376 382 var ta = $(textAreaId).get(0);
377 383 var initialHeight = '100px';
378 384
379 385 // default options
380 386 var codeMirrorOptions = {
381 387 mode: "text",
382 388 lineNumbers: false,
383 389 indentUnit: 4,
384 390 theme: 'rc-input'
385 391 };
386 392
387 393 var codeMirrorInstance = CodeMirror.fromTextArea(ta, codeMirrorOptions);
388 394 // marker for manually set description
389 395 codeMirrorInstance._userDefinedDesc = false;
390 396 codeMirrorInstance.setSize(null, initialHeight);
391 397 codeMirrorInstance.on("change", function(instance, changeObj) {
392 398 var height = initialHeight;
393 399 var lines = instance.lineCount();
394 400 if (lines > 6 && lines < 20) {
395 401 height = "auto"
396 402 }
397 403 else if (lines >= 20) {
398 404 height = 20 * 15;
399 405 }
400 406 instance.setSize(null, height);
401 407
402 408 // detect if the change was trigger by auto desc, or user input
403 409 changeOrigin = changeObj.origin;
404 410
405 411 if (changeOrigin === "setValue") {
406 412 cmLog.debug('Change triggered by setValue');
407 413 }
408 414 else {
409 415 cmLog.debug('user triggered change !');
410 416 // set special marker to indicate user has created an input.
411 417 instance._userDefinedDesc = true;
412 418 }
413 419
414 420 });
415 421
416 422 return codeMirrorInstance
417 423 };
418 424
419 425 /**
420 426 * Reviewer autocomplete
421 427 */
422 428 var ReviewerAutoComplete = function(inputId) {
423 429 $(inputId).autocomplete({
424 430 serviceUrl: pyroutes.url('user_autocomplete_data'),
425 431 minChars:2,
426 432 maxHeight:400,
427 433 deferRequestBy: 300, //miliseconds
428 434 showNoSuggestionNotice: true,
429 435 tabDisabled: true,
430 436 autoSelectFirst: true,
431 437 params: { user_id: templateContext.rhodecode_user.user_id, user_groups:true, user_groups_expand:true, skip_default_user:true },
432 438 formatResult: autocompleteFormatResult,
433 439 lookupFilter: autocompleteFilterResult,
434 440 onSelect: function(element, data) {
435 441 var mandatory = false;
436 442 var reasons = [_gettext('added manually by "{0}"').format(templateContext.rhodecode_user.username)];
437 443
438 444 // add whole user groups
439 445 if (data.value_type == 'user_group') {
440 446 reasons.push(_gettext('member of "{0}"').format(data.value_display));
441 447
442 448 $.each(data.members, function(index, member_data) {
443 449 var reviewer = member_data;
444 450 reviewer['user_id'] = member_data['id'];
445 451 reviewer['gravatar_link'] = member_data['icon_link'];
446 452 reviewer['user_link'] = member_data['profile_link'];
447 453 reviewer['rules'] = [];
448 454 reviewersController.addReviewMember(reviewer, reasons, mandatory);
449 455 })
450 456 }
451 457 // add single user
452 458 else {
453 459 var reviewer = data;
454 460 reviewer['user_id'] = data['id'];
455 461 reviewer['gravatar_link'] = data['icon_link'];
456 462 reviewer['user_link'] = data['profile_link'];
457 463 reviewer['rules'] = [];
458 464 reviewersController.addReviewMember(reviewer, reasons, mandatory);
459 465 }
460 466
461 467 $(inputId).val('');
462 468 }
463 469 });
464 470 };
465 471
466 472
467 473 VersionController = function () {
468 474 var self = this;
469 475 this.$verSource = $('input[name=ver_source]');
470 476 this.$verTarget = $('input[name=ver_target]');
471 477 this.$showVersionDiff = $('#show-version-diff');
472 478
473 479 this.adjustRadioSelectors = function (curNode) {
474 480 var getVal = function (item) {
475 481 if (item == 'latest') {
476 482 return Number.MAX_SAFE_INTEGER
477 483 }
478 484 else {
479 485 return parseInt(item)
480 486 }
481 487 };
482 488
483 489 var curVal = getVal($(curNode).val());
484 490 var cleared = false;
485 491
486 492 $.each(self.$verSource, function (index, value) {
487 493 var elVal = getVal($(value).val());
488 494
489 495 if (elVal > curVal) {
490 496 if ($(value).is(':checked')) {
491 497 cleared = true;
492 498 }
493 499 $(value).attr('disabled', 'disabled');
494 500 $(value).removeAttr('checked');
495 501 $(value).css({'opacity': 0.1});
496 502 }
497 503 else {
498 504 $(value).css({'opacity': 1});
499 505 $(value).removeAttr('disabled');
500 506 }
501 507 });
502 508
503 509 if (cleared) {
504 510 // if we unchecked an active, set the next one to same loc.
505 511 $(this.$verSource).filter('[value={0}]'.format(
506 512 curVal)).attr('checked', 'checked');
507 513 }
508 514
509 515 self.setLockAction(false,
510 516 $(curNode).data('verPos'),
511 517 $(this.$verSource).filter(':checked').data('verPos')
512 518 );
513 519 };
514 520
515 521
516 522 this.attachVersionListener = function () {
517 523 self.$verTarget.change(function (e) {
518 524 self.adjustRadioSelectors(this)
519 525 });
520 526 self.$verSource.change(function (e) {
521 527 self.adjustRadioSelectors(self.$verTarget.filter(':checked'))
522 528 });
523 529 };
524 530
525 531 this.init = function () {
526 532
527 533 var curNode = self.$verTarget.filter(':checked');
528 534 self.adjustRadioSelectors(curNode);
529 535 self.setLockAction(true);
530 536 self.attachVersionListener();
531 537
532 538 };
533 539
534 540 this.setLockAction = function (state, selectedVersion, otherVersion) {
535 541 var $showVersionDiff = this.$showVersionDiff;
536 542
537 543 if (state) {
538 544 $showVersionDiff.attr('disabled', 'disabled');
539 545 $showVersionDiff.addClass('disabled');
540 546 $showVersionDiff.html($showVersionDiff.data('labelTextLocked'));
541 547 }
542 548 else {
543 549 $showVersionDiff.removeAttr('disabled');
544 550 $showVersionDiff.removeClass('disabled');
545 551
546 552 if (selectedVersion == otherVersion) {
547 553 $showVersionDiff.html($showVersionDiff.data('labelTextShow'));
548 554 } else {
549 555 $showVersionDiff.html($showVersionDiff.data('labelTextDiff'));
550 556 }
551 557 }
552 558
553 559 };
554 560
555 561 this.showVersionDiff = function () {
556 562 var target = self.$verTarget.filter(':checked');
557 563 var source = self.$verSource.filter(':checked');
558 564
559 565 if (target.val() && source.val()) {
560 566 var params = {
561 567 'pull_request_id': templateContext.pull_request_data.pull_request_id,
562 568 'repo_name': templateContext.repo_name,
563 569 'version': target.val(),
564 570 'from_version': source.val()
565 571 };
566 572 window.location = pyroutes.url('pullrequest_show', params)
567 573 }
568 574
569 575 return false;
570 576 };
571 577
572 578 this.toggleVersionView = function (elem) {
573 579
574 580 if (this.$showVersionDiff.is(':visible')) {
575 581 $('.version-pr').hide();
576 582 this.$showVersionDiff.hide();
577 583 $(elem).html($(elem).data('toggleOn'))
578 584 } else {
579 585 $('.version-pr').show();
580 586 this.$showVersionDiff.show();
581 587 $(elem).html($(elem).data('toggleOff'))
582 588 }
583 589
584 590 return false
585 591 }
586 592
587 593 }; No newline at end of file
@@ -1,530 +1,537 b''
1 1 <%inherit file="/base/base.mako"/>
2 2
3 3 <%def name="title()">
4 4 ${c.repo_name} ${_('New pull request')}
5 5 </%def>
6 6
7 7 <%def name="breadcrumbs_links()">
8 8 ${_('New pull request')}
9 9 </%def>
10 10
11 11 <%def name="menu_bar_nav()">
12 12 ${self.menu_items(active='repositories')}
13 13 </%def>
14 14
15 15 <%def name="menu_bar_subnav()">
16 16 ${self.repo_menu(active='showpullrequest')}
17 17 </%def>
18 18
19 19 <%def name="main()">
20 20 <div class="box">
21 21 <div class="title">
22 22 ${self.repo_page_title(c.rhodecode_db_repo)}
23 23 </div>
24 24
25 25 ${h.secure_form(h.route_path('pullrequest_create', repo_name=c.repo_name, _query=request.GET.mixed()), id='pull_request_form', request=request)}
26 26
27 27 ${self.breadcrumbs()}
28 28
29 29 <div class="box pr-summary">
30 30
31 31 <div class="summary-details block-left">
32 32
33 33
34 34 <div class="pr-details-title">
35 35 ${_('Pull request summary')}
36 36 </div>
37 37
38 38 <div class="form" style="padding-top: 10px">
39 39 <!-- fields -->
40 40
41 41 <div class="fields" >
42 42
43 43 <div class="field">
44 44 <div class="label">
45 45 <label for="pullrequest_title">${_('Title')}:</label>
46 46 </div>
47 47 <div class="input">
48 48 ${h.text('pullrequest_title', c.default_title, class_="medium autogenerated-title")}
49 49 </div>
50 50 </div>
51 51
52 52 <div class="field">
53 53 <div class="label label-textarea">
54 54 <label for="pullrequest_desc">${_('Description')}:</label>
55 55 </div>
56 56 <div class="textarea text-area editor">
57 57 ${h.textarea('pullrequest_desc',size=30, )}
58 58 <span class="help-block">${_('Write a short description on this pull request')}</span>
59 59 </div>
60 60 </div>
61 61
62 62 <div class="field">
63 63 <div class="label label-textarea">
64 64 <label for="pullrequest_desc">${_('Commit flow')}:</label>
65 65 </div>
66 66
67 67 ## TODO: johbo: Abusing the "content" class here to get the
68 68 ## desired effect. Should be replaced by a proper solution.
69 69
70 70 ##ORG
71 71 <div class="content">
72 72 <strong>${_('Source repository')}:</strong>
73 73 ${c.rhodecode_db_repo.description}
74 74 </div>
75 75 <div class="content">
76 76 ${h.hidden('source_repo')}
77 77 ${h.hidden('source_ref')}
78 78 </div>
79 79
80 80 ##OTHER, most Probably the PARENT OF THIS FORK
81 81 <div class="content">
82 82 ## filled with JS
83 83 <div id="target_repo_desc"></div>
84 84 </div>
85 85
86 86 <div class="content">
87 87 ${h.hidden('target_repo')}
88 88 ${h.hidden('target_ref')}
89 89 <span id="target_ref_loading" style="display: none">
90 90 ${_('Loading refs...')}
91 91 </span>
92 92 </div>
93 93 </div>
94 94
95 95 <div class="field">
96 96 <div class="label label-textarea">
97 97 <label for="pullrequest_submit"></label>
98 98 </div>
99 99 <div class="input">
100 100 <div class="pr-submit-button">
101 101 ${h.submit('save',_('Submit Pull Request'),class_="btn")}
102 102 </div>
103 103 <div id="pr_open_message"></div>
104 104 </div>
105 105 </div>
106 106
107 107 <div class="pr-spacing-container"></div>
108 108 </div>
109 109 </div>
110 110 </div>
111 111 <div>
112 112 ## AUTHOR
113 113 <div class="reviewers-title block-right">
114 114 <div class="pr-details-title">
115 115 ${_('Author of this pull request')}
116 116 </div>
117 117 </div>
118 118 <div class="block-right pr-details-content reviewers">
119 119 <ul class="group_members">
120 120 <li>
121 121 ${self.gravatar_with_user(c.rhodecode_user.email, 16)}
122 122 </li>
123 123 </ul>
124 124 </div>
125 125
126 126 ## REVIEW RULES
127 127 <div id="review_rules" style="display: none" class="reviewers-title block-right">
128 128 <div class="pr-details-title">
129 129 ${_('Reviewer rules')}
130 130 </div>
131 131 <div class="pr-reviewer-rules">
132 132 ## review rules will be appended here, by default reviewers logic
133 133 </div>
134 134 </div>
135 135
136 136 ## REVIEWERS
137 137 <div class="reviewers-title block-right">
138 138 <div class="pr-details-title">
139 139 ${_('Pull request reviewers')}
140 140 <span class="calculate-reviewers"> - ${_('loading...')}</span>
141 141 </div>
142 142 </div>
143 143 <div id="reviewers" class="block-right pr-details-content reviewers">
144 144 ## members goes here, filled via JS based on initial selection !
145 145 <input type="hidden" name="__start__" value="review_members:sequence">
146 146 <ul id="review_members" class="group_members"></ul>
147 147 <input type="hidden" name="__end__" value="review_members:sequence">
148 148 <div id="add_reviewer_input" class='ac'>
149 149 <div class="reviewer_ac">
150 150 ${h.text('user', class_='ac-input', placeholder=_('Add reviewer or reviewer group'))}
151 151 <div id="reviewers_container"></div>
152 152 </div>
153 153 </div>
154 154 </div>
155 155 </div>
156 156 </div>
157 157 <div class="box">
158 158 <div>
159 159 ## overview pulled by ajax
160 160 <div id="pull_request_overview"></div>
161 161 </div>
162 162 </div>
163 163 ${h.end_form()}
164 164 </div>
165 165
166 166 <script type="text/javascript">
167 167 $(function(){
168 168 var defaultSourceRepo = '${c.default_repo_data['source_repo_name']}';
169 169 var defaultSourceRepoData = ${c.default_repo_data['source_refs_json']|n};
170 170 var defaultTargetRepo = '${c.default_repo_data['target_repo_name']}';
171 171 var defaultTargetRepoData = ${c.default_repo_data['target_refs_json']|n};
172 172
173 173 var $pullRequestForm = $('#pull_request_form');
174 174 var $sourceRepo = $('#source_repo', $pullRequestForm);
175 175 var $targetRepo = $('#target_repo', $pullRequestForm);
176 176 var $sourceRef = $('#source_ref', $pullRequestForm);
177 177 var $targetRef = $('#target_ref', $pullRequestForm);
178 178
179 179 var sourceRepo = function() { return $sourceRepo.eq(0).val() };
180 180 var sourceRef = function() { return $sourceRef.eq(0).val().split(':') };
181 181
182 182 var targetRepo = function() { return $targetRepo.eq(0).val() };
183 183 var targetRef = function() { return $targetRef.eq(0).val().split(':') };
184 184
185 185 var calculateContainerWidth = function() {
186 186 var maxWidth = 0;
187 187 var repoSelect2Containers = ['#source_repo', '#target_repo'];
188 188 $.each(repoSelect2Containers, function(idx, value) {
189 189 $(value).select2('container').width('auto');
190 190 var curWidth = $(value).select2('container').width();
191 191 if (maxWidth <= curWidth) {
192 192 maxWidth = curWidth;
193 193 }
194 194 $.each(repoSelect2Containers, function(idx, value) {
195 195 $(value).select2('container').width(maxWidth + 10);
196 196 });
197 197 });
198 198 };
199 199
200 200 var initRefSelection = function(selectedRef) {
201 201 return function(element, callback) {
202 202 // translate our select2 id into a text, it's a mapping to show
203 203 // simple label when selecting by internal ID.
204 204 var id, refData;
205 if (selectedRef === undefined) {
205 if (selectedRef === undefined || selectedRef === null) {
206 206 id = element.val();
207 207 refData = element.val().split(':');
208
209 if (refData.length !== 3){
210 refData = ["", "", ""]
211 }
208 212 } else {
209 213 id = selectedRef;
210 214 refData = selectedRef.split(':');
211 215 }
212 216
213 217 var text = refData[1];
214 218 if (refData[0] === 'rev') {
215 219 text = text.substring(0, 12);
216 220 }
217 221
218 222 var data = {id: id, text: text};
219
220 223 callback(data);
221 224 };
222 225 };
223 226
224 227 var formatRefSelection = function(item) {
225 228 var prefix = '';
226 229 var refData = item.id.split(':');
227 230 if (refData[0] === 'branch') {
228 231 prefix = '<i class="icon-branch"></i>';
229 232 }
230 233 else if (refData[0] === 'book') {
231 234 prefix = '<i class="icon-bookmark"></i>';
232 235 }
233 236 else if (refData[0] === 'tag') {
234 237 prefix = '<i class="icon-tag"></i>';
235 238 }
236 239
237 240 var originalOption = item.element;
238 241 return prefix + item.text;
239 242 };
240 243
241 244 // custom code mirror
242 245 var codeMirrorInstance = initPullRequestsCodeMirror('#pullrequest_desc');
243 246
244 247 reviewersController = new ReviewersController();
245 248
246 249 var queryTargetRepo = function(self, query) {
247 250 // cache ALL results if query is empty
248 251 var cacheKey = query.term || '__';
249 252 var cachedData = self.cachedDataSource[cacheKey];
250 253
251 254 if (cachedData) {
252 255 query.callback({results: cachedData.results});
253 256 } else {
254 257 $.ajax({
255 258 url: pyroutes.url('pullrequest_repo_destinations', {'repo_name': templateContext.repo_name}),
256 259 data: {query: query.term},
257 260 dataType: 'json',
258 261 type: 'GET',
259 262 success: function(data) {
260 263 self.cachedDataSource[cacheKey] = data;
261 264 query.callback({results: data.results});
262 265 },
263 266 error: function(data, textStatus, errorThrown) {
264 267 alert(
265 268 "Error while fetching entries.\nError code {0} ({1}).".format(data.status, data.statusText));
266 269 }
267 270 });
268 271 }
269 272 };
270 273
271 274 var queryTargetRefs = function(initialData, query) {
272 275 var data = {results: []};
273 276 // filter initialData
274 277 $.each(initialData, function() {
275 278 var section = this.text;
276 279 var children = [];
277 280 $.each(this.children, function() {
278 281 if (query.term.length === 0 ||
279 282 this.text.toUpperCase().indexOf(query.term.toUpperCase()) >= 0 ) {
280 283 children.push({'id': this.id, 'text': this.text})
281 284 }
282 285 });
283 286 data.results.push({'text': section, 'children': children})
284 287 });
285 288 query.callback({results: data.results});
286 289 };
287 290
288 291 var loadRepoRefDiffPreview = function() {
289 292
290 293 var url_data = {
291 294 'repo_name': targetRepo(),
292 295 'target_repo': sourceRepo(),
293 296 'source_ref': targetRef()[2],
294 297 'source_ref_type': 'rev',
295 298 'target_ref': sourceRef()[2],
296 299 'target_ref_type': 'rev',
297 300 'merge': true,
298 301 '_': Date.now() // bypass browser caching
299 302 }; // gather the source/target ref and repo here
300 303
301 304 if (sourceRef().length !== 3 || targetRef().length !== 3) {
302 305 prButtonLock(true, "${_('Please select source and target')}");
303 306 return;
304 307 }
305 308 var url = pyroutes.url('repo_compare', url_data);
306 309
307 310 // lock PR button, so we cannot send PR before it's calculated
308 311 prButtonLock(true, "${_('Loading compare ...')}", 'compare');
309 312
310 313 if (loadRepoRefDiffPreview._currentRequest) {
311 314 loadRepoRefDiffPreview._currentRequest.abort();
312 315 }
313 316
314 317 loadRepoRefDiffPreview._currentRequest = $.get(url)
315 318 .error(function(data, textStatus, errorThrown) {
316 319 if (textStatus !== 'abort') {
317 320 alert(
318 321 "Error while processing request.\nError code {0} ({1}).".format(
319 322 data.status, data.statusText));
320 323 }
321 324
322 325 })
323 326 .done(function(data) {
324 327 loadRepoRefDiffPreview._currentRequest = null;
325 328 $('#pull_request_overview').html(data);
326 329
327 330 var commitElements = $(data).find('tr[commit_id]');
328 331
329 332 var prTitleAndDesc = getTitleAndDescription(
330 333 sourceRef()[1], commitElements, 5);
331 334
332 335 var title = prTitleAndDesc[0];
333 336 var proposedDescription = prTitleAndDesc[1];
334 337
335 338 var useGeneratedTitle = (
336 339 $('#pullrequest_title').hasClass('autogenerated-title') ||
337 340 $('#pullrequest_title').val() === "");
338 341
339 342 if (title && useGeneratedTitle) {
340 343 // use generated title if we haven't specified our own
341 344 $('#pullrequest_title').val(title);
342 345 $('#pullrequest_title').addClass('autogenerated-title');
343 346
344 347 }
345 348
346 349 var useGeneratedDescription = (
347 350 !codeMirrorInstance._userDefinedDesc ||
348 351 codeMirrorInstance.getValue() === "");
349 352
350 353 if (proposedDescription && useGeneratedDescription) {
351 354 // set proposed content, if we haven't defined our own,
352 355 // or we don't have description written
353 356 codeMirrorInstance._userDefinedDesc = false; // reset state
354 357 codeMirrorInstance.setValue(proposedDescription);
355 358 }
356 359
357 360 var msg = '';
358 361 if (commitElements.length === 1) {
359 362 msg = "${_ungettext('This pull request will consist of __COMMITS__ commit.', 'This pull request will consist of __COMMITS__ commits.', 1)}";
360 363 } else {
361 364 msg = "${_ungettext('This pull request will consist of __COMMITS__ commit.', 'This pull request will consist of __COMMITS__ commits.', 2)}";
362 365 }
363 366
364 367 msg += ' <a id="pull_request_overview_url" href="{0}" target="_blank">${_('Show detailed compare.')}</a>'.format(url);
365 368
366 369 if (commitElements.length) {
367 370 var commitsLink = '<a href="#pull_request_overview"><strong>{0}</strong></a>'.format(commitElements.length);
368 371 prButtonLock(false, msg.replace('__COMMITS__', commitsLink), 'compare');
369 372 }
370 373 else {
371 374 prButtonLock(true, "${_('There are no commits to merge.')}", 'compare');
372 375 }
373 376
374 377
375 378 });
376 379 };
377 380
378 381 var Select2Box = function(element, overrides) {
379 382 var globalDefaults = {
380 383 dropdownAutoWidth: true,
381 384 containerCssClass: "drop-menu",
382 385 dropdownCssClass: "drop-menu-dropdown"
383 386 };
384 387
385 388 var initSelect2 = function(defaultOptions) {
386 389 var options = jQuery.extend(globalDefaults, defaultOptions, overrides);
387 390 element.select2(options);
388 391 };
389 392
390 393 return {
391 394 initRef: function() {
392 395 var defaultOptions = {
393 396 minimumResultsForSearch: 5,
394 397 formatSelection: formatRefSelection
395 398 };
396 399
397 400 initSelect2(defaultOptions);
398 401 },
399 402
400 403 initRepo: function(defaultValue, readOnly) {
401 404 var defaultOptions = {
402 405 initSelection : function (element, callback) {
403 406 var data = {id: defaultValue, text: defaultValue};
404 407 callback(data);
405 408 }
406 409 };
407 410
408 411 initSelect2(defaultOptions);
409 412
410 413 element.select2('val', defaultSourceRepo);
411 414 if (readOnly === true) {
412 415 element.select2('readonly', true);
413 416 }
414 417 }
415 418 };
416 419 };
417 420
418 var initTargetRefs = function(refsData, selectedRef){
421 var initTargetRefs = function(refsData, selectedRef) {
422
419 423 Select2Box($targetRef, {
424 placeholder: "${_('Select commit reference')}",
420 425 query: function(query) {
421 426 queryTargetRefs(refsData, query);
422 427 },
423 428 initSelection : initRefSelection(selectedRef)
424 429 }).initRef();
425 430
426 431 if (!(selectedRef === undefined)) {
427 432 $targetRef.select2('val', selectedRef);
428 433 }
429 434 };
430 435
431 436 var targetRepoChanged = function(repoData) {
432 437 // generate new DESC of target repo displayed next to select
433 438 var prLink = pyroutes.url('pullrequest_new', {'repo_name': repoData['name']});
434 439 $('#target_repo_desc').html(
435 440 "<strong>${_('Target repository')}</strong>: {0}. <a href=\"{1}\">Switch base, and use as source.</a>".format(repoData['description'], prLink)
436 441 );
437 442
438 443 // generate dynamic select2 for refs.
439 444 initTargetRefs(repoData['refs']['select2_refs'],
440 445 repoData['refs']['selected_ref']);
441 446
442 447 };
443 448
444 449 var sourceRefSelect2 = Select2Box($sourceRef, {
445 450 placeholder: "${_('Select commit reference')}",
446 451 query: function(query) {
447 452 var initialData = defaultSourceRepoData['refs']['select2_refs'];
448 453 queryTargetRefs(initialData, query)
449 454 },
450 455 initSelection: initRefSelection()
451 456 }
452 457 );
453 458
454 459 var sourceRepoSelect2 = Select2Box($sourceRepo, {
455 460 query: function(query) {}
456 461 });
457 462
458 463 var targetRepoSelect2 = Select2Box($targetRepo, {
459 464 cachedDataSource: {},
460 465 query: $.debounce(250, function(query) {
461 466 queryTargetRepo(this, query);
462 467 }),
463 468 formatResult: formatResult
464 469 });
465 470
466 471 sourceRefSelect2.initRef();
467 472
468 473 sourceRepoSelect2.initRepo(defaultSourceRepo, true);
469 474
470 475 targetRepoSelect2.initRepo(defaultTargetRepo, false);
471 476
472 477 $sourceRef.on('change', function(e){
473 478 loadRepoRefDiffPreview();
474 479 reviewersController.loadDefaultReviewers(
475 480 sourceRepo(), sourceRef(), targetRepo(), targetRef());
476 481 });
477 482
478 483 $targetRef.on('change', function(e){
479 484 loadRepoRefDiffPreview();
480 485 reviewersController.loadDefaultReviewers(
481 486 sourceRepo(), sourceRef(), targetRepo(), targetRef());
482 487 });
483 488
484 489 $targetRepo.on('change', function(e){
485 490 var repoName = $(this).val();
486 491 calculateContainerWidth();
487 492 $targetRef.select2('destroy');
488 493 $('#target_ref_loading').show();
489 494
490 495 $.ajax({
491 496 url: pyroutes.url('pullrequest_repo_refs',
492 497 {'repo_name': templateContext.repo_name, 'target_repo_name':repoName}),
493 498 data: {},
494 499 dataType: 'json',
495 500 type: 'GET',
496 501 success: function(data) {
497 502 $('#target_ref_loading').hide();
498 503 targetRepoChanged(data);
499 504 loadRepoRefDiffPreview();
500 505 },
501 506 error: function(data, textStatus, errorThrown) {
502 507 alert("Error while fetching entries.\nError code {0} ({1}).".format(data.status, data.statusText));
503 508 }
504 509 })
505 510
506 511 });
507 512
508 513 prButtonLock(true, "${_('Please select source and target')}", 'all');
509 514
510 515 // auto-load on init, the target refs select2
511 516 calculateContainerWidth();
512 517 targetRepoChanged(defaultTargetRepoData);
513 518
514 519 $('#pullrequest_title').on('keyup', function(e){
515 520 $(this).removeClass('autogenerated-title');
516 521 });
517 522
518 523 % if c.default_source_ref:
519 524 // in case we have a pre-selected value, use it now
520 525 $sourceRef.select2('val', '${c.default_source_ref}');
526 // diff preview load
521 527 loadRepoRefDiffPreview();
528 // default reviewers
522 529 reviewersController.loadDefaultReviewers(
523 530 sourceRepo(), sourceRef(), targetRepo(), targetRef());
524 531 % endif
525 532
526 533 ReviewerAutoComplete('#user');
527 534 });
528 535 </script>
529 536
530 537 </%def>
General Comments 0
You need to be logged in to leave comments. Login now