plz / pr_routes.v
599 lines · 571 sloc · 20.14 KB · afdfff432d46a86efc1c7b2b324448dd946900e2
Raw
1// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved.
2// Use of this source code is governed by a GPL license that can be found in the LICENSE file.
3module main
4
5import veb
6import validation
7import git
8import time
9
10struct PrWithUser {
11 pr PullRequest
12 user User
13}
14
15struct PrCommentWithUser {
16 item PrComment
17 user User
18}
19
20struct PrReviewWithUser {
21 review PrReview
22 user User
23 comments []PrReviewComment
24}
25
26struct PrTimelineEntry {
27mut:
28 kind string // 'comment' or 'review'
29 created_at int
30 user User
31 comment PrComment
32 review PrReview
33 rcomments []PrReviewCommentWithUser
34}
35
36struct PrReviewCommentWithUser {
37 item PrReviewComment
38 user User
39}
40
41// GET /:username/:repo_name/pulls
42@['/:username/:repo_name/pulls']
43pub fn (mut app App) handle_get_repo_pulls(mut ctx Context, username string, repo_name string) veb.Result {
44 return app.repo_pulls(mut ctx, username, repo_name, 'open')
45}
46
47@['/:username/:repo_name/pulls/:tab']
48pub fn (mut app App) repo_pulls(mut ctx Context, username string, repo_name string, tab string) veb.Result {
49 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
50 if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public {
51 return ctx.not_found()
52 }
53 current_tab := if tab in ['open', 'closed', 'merged'] { tab } else { 'open' }
54 status := match current_tab {
55 'closed' { PrStatus.closed }
56 'merged' { PrStatus.merged }
57 else { PrStatus.open }
58 }
59
60 prs := app.find_repo_pull_requests(repo.id, status)
61 mut prs_with_users := []PrWithUser{}
62 for pr in prs {
63 author := app.get_user_by_id(pr.author_id) or { continue }
64 prs_with_users << PrWithUser{
65 pr: pr
66 user: author
67 }
68 }
69 _ := app.get_repo_open_pr_count(repo.id)
70 tab_open_class := if current_tab == 'open' { 'pr-tab pr-tab--active' } else { 'pr-tab' }
71 tab_merged_class := if current_tab == 'merged' { 'pr-tab pr-tab--active' } else { 'pr-tab' }
72 tab_closed_class := if current_tab == 'closed' { 'pr-tab pr-tab--active' } else { 'pr-tab' }
73 return $veb.html('templates/pulls.html')
74}
75
76// GET /:username/:repo_name/pulls/new
77@['/:username/:repo_name/compare']
78pub fn (mut app App) new_pull_request_form(mut ctx Context, username string, repo_name string) veb.Result {
79 if !ctx.logged_in {
80 return ctx.redirect_to_login()
81 }
82 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
83 if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public {
84 return ctx.not_found()
85 }
86 branches := app.get_all_repo_branches(repo.id)
87 base := if 'base' in ctx.query { ctx.query['base'] } else { repo.primary_branch }
88 head := if 'head' in ctx.query { ctx.query['head'] } else { '' }
89 mut commits := []Commit{}
90 mut file_diffs := []FileDiff{}
91 mut suggested_title := ''
92 mut error_msg := ''
93 mut has_compare := false
94 if head != '' && head != base {
95 has_compare = true
96 if !app.contains_repo_branch(repo.id, head) || !app.contains_repo_branch(repo.id, base) {
97 error_msg = 'Both base and compare branches must exist in this repository.'
98 has_compare = false
99 } else {
100 commits = repo.list_commits_between(base, head)
101 raw_diff := repo.diff_branches(base, head)
102 file_diffs = parse_unified_diff(raw_diff)
103 if commits.len > 0 {
104 suggested_title = commits[0].message
105 }
106 }
107 }
108 return $veb.html('templates/new/pull.html')
109}
110
111// POST /:username/:repo_name/pulls
112@['/:username/:repo_name/pulls'; post]
113pub fn (mut app App) handle_create_pull_request(mut ctx Context, username string, repo_name string) veb.Result {
114 if !ctx.logged_in {
115 return ctx.redirect_to_login()
116 }
117 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
118 if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public {
119 return ctx.not_found()
120 }
121 title := ctx.form['title']
122 description := ctx.form['description']
123 head := ctx.form['head']
124 base := ctx.form['base']
125 if validation.is_string_empty(title) || validation.is_string_empty(head)
126 || validation.is_string_empty(base) {
127 ctx.error('Title, head and base branches are required')
128 return ctx.redirect('/${username}/${repo_name}/compare?base=${base}&head=${head}')
129 }
130 if head == base {
131 ctx.error('Head and base must differ')
132 return ctx.redirect('/${username}/${repo_name}/compare')
133 }
134 if !app.contains_repo_branch(repo.id, head) || !app.contains_repo_branch(repo.id, base) {
135 ctx.error('Branches not found')
136 return ctx.redirect('/${username}/${repo_name}/compare')
137 }
138 commits := repo.list_commits_between(base, head)
139 if commits.len == 0 {
140 ctx.error('No commits between base and head')
141 return ctx.redirect('/${username}/${repo_name}/compare?base=${base}&head=${head}')
142 }
143 pr_id := app.add_pull_request(repo.id, ctx.user.id, title, description, head, base) or {
144 ctx.error('Could not create pull request')
145 return ctx.redirect('/${username}/${repo_name}/compare')
146 }
147 app.increment_repo_open_prs(repo.id) or { app.info(err.str()) }
148 app.dispatch_webhook(repo.id, 'pr', WebhookPrPayload{
149 action: 'opened'
150 repo: '${username}/${repo_name}'
151 number: pr_id
152 title: title
153 author: ctx.user.username
154 head: head
155 base: base
156 })
157 return ctx.redirect('/${username}/${repo_name}/pull/${pr_id}')
158}
159
160// GET /:username/:repo_name/pull/:id
161@['/:username/:repo_name/pull/:id']
162pub fn (mut app App) pull_request(mut ctx Context, username string, repo_name string, id string) veb.Result {
163 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
164 if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public {
165 return ctx.not_found()
166 }
167 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
168 if pr.repo_id != repo.id {
169 return ctx.not_found()
170 }
171 author := app.get_user_by_id(pr.author_id) or { return ctx.not_found() }
172 commits := repo.list_commits_between(pr.base_branch, pr.head_branch)
173 comments := app.get_pr_comments(pr.id)
174 reviews := app.get_pr_reviews(pr.id)
175 rcomments := app.get_pr_review_comments(pr.id)
176 mut timeline := []PrTimelineEntry{}
177 for c in comments {
178 u := app.get_user_by_id(c.author_id) or { continue }
179 timeline << PrTimelineEntry{
180 kind: 'comment'
181 created_at: c.created_at
182 user: u
183 comment: c
184 }
185 }
186 for r in reviews {
187 u := app.get_user_by_id(r.author_id) or { continue }
188 mut r_comments := []PrReviewCommentWithUser{}
189 for rc in rcomments {
190 if rc.review_id == r.id {
191 uu := app.get_user_by_id(rc.author_id) or { continue }
192 r_comments << PrReviewCommentWithUser{
193 item: rc
194 user: uu
195 }
196 }
197 }
198 timeline << PrTimelineEntry{
199 kind: 'review'
200 created_at: r.created_at
201 user: u
202 review: r
203 rcomments: r_comments
204 }
205 }
206 timeline.sort(a.created_at < b.created_at)
207 is_repo_owner := repo.user_id == ctx.user.id
208 can_merge := is_repo_owner && pr.is_open()
209 can_close := pr.is_open() && (is_repo_owner || pr.author_id == ctx.user.id)
210 can_reopen := pr.is_closed() && (is_repo_owner || pr.author_id == ctx.user.id)
211 return $veb.html('templates/pull.html')
212}
213
214// GET /:username/:repo_name/pull/:id/files
215@['/:username/:repo_name/pull/:id/files']
216pub fn (mut app App) pull_request_files(mut ctx Context, username string, repo_name string, id string) veb.Result {
217 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
218 if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public {
219 return ctx.not_found()
220 }
221 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
222 if pr.repo_id != repo.id {
223 return ctx.not_found()
224 }
225 author := app.get_user_by_id(pr.author_id) or { return ctx.not_found() }
226 raw_diff := repo.diff_branches(pr.base_branch, pr.head_branch)
227 file_diffs := parse_unified_diff(raw_diff)
228 rcomments := app.get_pr_review_comments(pr.id)
229 mut comments_by_key := map[string][]PrReviewCommentWithUser{}
230 for rc in rcomments {
231 u := app.get_user_by_id(rc.author_id) or { continue }
232 key := '${rc.file_path}|${rc.side}|${rc.line_number}'
233 comments_by_key[key] << PrReviewCommentWithUser{
234 item: rc
235 user: u
236 }
237 }
238 return $veb.html('templates/pull_files.html')
239}
240
241// POST /:username/:repo_name/pull/:id/comments
242@['/:username/:repo_name/pull/:id/comments'; post]
243pub fn (mut app App) handle_add_pr_comment(mut ctx Context, username string, repo_name string, id string) veb.Result {
244 if !ctx.logged_in {
245 return ctx.redirect_to_login()
246 }
247 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
248 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
249 if pr.repo_id != repo.id {
250 return ctx.not_found()
251 }
252 text := ctx.form['text']
253 if validation.is_string_empty(text) {
254 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
255 }
256 app.add_pr_comment(pr.id, ctx.user.id, text) or {
257 ctx.error('Could not add comment')
258 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
259 }
260 app.increment_pr_comments(pr.id) or { app.info(err.str()) }
261 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
262}
263
264// POST /:username/:repo_name/pull/:id/review
265@['/:username/:repo_name/pull/:id/review'; post]
266pub fn (mut app App) handle_submit_review(mut ctx Context, username string, repo_name string, id string) veb.Result {
267 if !ctx.logged_in {
268 return ctx.redirect_to_login()
269 }
270 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
271 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
272 if pr.repo_id != repo.id {
273 return ctx.not_found()
274 }
275 body := ctx.form['body']
276 state_str := ctx.form['state']
277 state := match state_str {
278 'approved' { 1 }
279 'changes_requested' { 2 }
280 else { 0 }
281 }
282
283 review_id := app.add_pr_review(pr.id, ctx.user.id, state, body) or {
284 ctx.error('Could not submit review')
285 return ctx.redirect('/${username}/${repo_name}/pull/${id}/files')
286 }
287 // Attach pending line comments from form (file_path|side|line — text)
288 for key, val in ctx.form {
289 if !key.starts_with('rc::') {
290 continue
291 }
292 text := val.trim_space()
293 if text == '' {
294 continue
295 }
296 // rc::file::side::line
297 parts := key[4..].split('::')
298 if parts.len < 3 {
299 continue
300 }
301 file_path := parts[0]
302 side := parts[1]
303 line_no := parts[2].int()
304 app.add_pr_review_comment(pr.id, ctx.user.id, review_id, file_path, line_no, side, text) or {
305 continue
306 }
307 }
308 if body != '' {
309 app.increment_pr_comments(pr.id) or {}
310 }
311 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
312}
313
314// POST /:username/:repo_name/pull/:id/line-comment
315@['/:username/:repo_name/pull/:id/line-comment'; post]
316pub fn (mut app App) handle_add_line_comment(mut ctx Context, username string, repo_name string, id string) veb.Result {
317 if !ctx.logged_in {
318 return ctx.redirect_to_login()
319 }
320 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
321 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
322 if pr.repo_id != repo.id {
323 return ctx.not_found()
324 }
325 file_path := ctx.form['file_path']
326 side := ctx.form['side']
327 line_no := ctx.form['line_number'].int()
328 text := ctx.form['text']
329 if validation.is_string_empty(text) || validation.is_string_empty(file_path) {
330 return ctx.redirect('/${username}/${repo_name}/pull/${id}/files')
331 }
332 app.add_pr_review_comment(pr.id, ctx.user.id, 0, file_path, line_no, side, text) or {
333 ctx.error('Could not add line comment')
334 return ctx.redirect('/${username}/${repo_name}/pull/${id}/files')
335 }
336 return ctx.redirect('/${username}/${repo_name}/pull/${id}/files#${file_path}-${side}-${line_no}')
337}
338
339// POST /:username/:repo_name/pull/:id/close
340@['/:username/:repo_name/pull/:id/close'; post]
341pub fn (mut app App) handle_close_pr(mut ctx Context, username string, repo_name string, id string) veb.Result {
342 if !ctx.logged_in {
343 return ctx.redirect_to_login()
344 }
345 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
346 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
347 if pr.repo_id != repo.id {
348 return ctx.not_found()
349 }
350 can_close := repo.user_id == ctx.user.id || pr.author_id == ctx.user.id
351 if !can_close {
352 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
353 }
354 if !pr.is_open() {
355 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
356 }
357 app.set_pr_status(pr.id, .closed) or {
358 ctx.error('Could not close PR')
359 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
360 }
361 app.decrement_repo_open_prs(repo.id) or {}
362 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
363}
364
365// POST /:username/:repo_name/pull/:id/reopen
366@['/:username/:repo_name/pull/:id/reopen'; post]
367pub fn (mut app App) handle_reopen_pr(mut ctx Context, username string, repo_name string, id string) veb.Result {
368 if !ctx.logged_in {
369 return ctx.redirect_to_login()
370 }
371 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
372 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
373 if pr.repo_id != repo.id {
374 return ctx.not_found()
375 }
376 can_reopen := repo.user_id == ctx.user.id || pr.author_id == ctx.user.id
377 if !can_reopen {
378 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
379 }
380 if !pr.is_closed() {
381 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
382 }
383 if !app.contains_repo_branch(repo.id, pr.head_branch)
384 || !app.contains_repo_branch(repo.id, pr.base_branch) {
385 ctx.error('Cannot reopen: head or base branch is missing')
386 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
387 }
388 app.set_pr_status(pr.id, .open) or {
389 ctx.error('Could not reopen PR')
390 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
391 }
392 app.increment_repo_open_prs(repo.id) or {}
393 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
394}
395
396// POST /:username/:repo_name/pull/:id/merge
397@['/:username/:repo_name/pull/:id/merge'; post]
398pub fn (mut app App) handle_merge_pr(mut ctx Context, username string, repo_name string, id string) veb.Result {
399 if !ctx.logged_in {
400 return ctx.redirect_to_login()
401 }
402 mut repo := app.find_repo_by_name_and_username(repo_name, username) or {
403 return ctx.not_found()
404 }
405 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
406 if pr.repo_id != repo.id {
407 return ctx.not_found()
408 }
409 if repo.user_id != ctx.user.id {
410 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
411 }
412 if !pr.is_open() {
413 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
414 }
415 merge_message := 'Merge pull request #${pr.id} from ${pr.head_branch}\n\n${pr.title}'
416 merge_hash := merge_branches_in_bare(repo, pr.base_branch, pr.head_branch, ctx.user.username,
417 merge_message) or {
418 ctx.error('Merge failed: ${err}')
419 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
420 }
421 app.set_pr_merged(pr.id, merge_hash) or {
422 ctx.error('Merged but failed to update PR record')
423 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
424 }
425 app.decrement_repo_open_prs(repo.id) or {}
426 app.delete_repository_files_in_branch(repo.id, pr.base_branch) or {}
427 app.update_repo_after_push(repo.id, pr.base_branch) or {
428 app.warn('Failed to update repo after merge: ${err}')
429 }
430 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
431}
432
433// User-scoped PR list
434@['/:username/pulls']
435pub fn (mut app App) handle_get_user_pulls(mut ctx Context, username string) veb.Result {
436 if !ctx.logged_in {
437 return ctx.not_found()
438 }
439 exists, user := app.check_username(username)
440 if !exists {
441 return ctx.not_found()
442 }
443 mut prs := app.find_user_pull_requests(user.id)
444 mut prs_with_repo := []PullRequest{}
445 for mut pr in prs {
446 r := app.find_repo_by_id(pr.repo_id) or { continue }
447 pr.repo_author = r.user_name
448 pr.repo_name = r.name
449 prs_with_repo << pr
450 }
451 return $veb.html('templates/user_pulls.html')
452}
453
454// --- git helpers ---
455
456// list_commits_between returns commits in head not in base.
457fn (r Repo) list_commits_between(base string, head string) []Commit {
458 if base == '' || head == '' {
459 return []Commit{}
460 }
461 if !is_safe_ref(base) || !is_safe_ref(head) {
462 return []Commit{}
463 }
464 out :=
465 r.git('log ${base}..${head} --pretty=format:%h${log_field_separator}%aE${log_field_separator}%cD${log_field_separator}%s${log_field_separator}%aN')
466 mut commits := []Commit{}
467 for line in out.split_into_lines() {
468 args := line.split(log_field_separator)
469 if args.len < 5 {
470 continue
471 }
472 date := time.parse_rfc2822(args[2]) or { time.now() }
473 commits << Commit{
474 hash: args[0]
475 author: args[4]
476 message: args[3]
477 created_at: int(date.unix())
478 author_id: 0
479 }
480 }
481 return commits
482}
483
484// diff_branches returns the unified diff between base and head.
485fn (r Repo) diff_branches(base string, head string) string {
486 if base == '' || head == '' {
487 return ''
488 }
489 if !is_safe_ref(base) || !is_safe_ref(head) {
490 return ''
491 }
492 return r.git('diff --no-color ${base}...${head}')
493}
494
495// merge_branches_in_bare performs a merge inside a bare repo using
496// git merge-tree to compute the resulting tree, then commit-tree
497// and update-ref to advance the base branch. Returns the merge commit hash.
498fn merge_branches_in_bare(repo Repo, base string, head string, author string, message string) !string {
499 if !is_safe_ref(base) || !is_safe_ref(head) {
500 return error('invalid branch name')
501 }
502 git_dir := repo.git_dir
503 base_sha := sh('git -C ${git_dir} rev-parse ${base}')
504 head_sha := sh('git -C ${git_dir} rev-parse ${head}')
505 if base_sha == '' || head_sha == '' {
506 return error('branch refs missing')
507 }
508 // Try fast-forward first: if base is an ancestor of head, fast-forward.
509 is_ancestor_result :=
510 git.Git.exec_shell('git -C ${git_dir} merge-base --is-ancestor ${base_sha} ${head_sha}')
511 if is_ancestor_result.exit_code == 0 {
512 r := git.Git.exec_in_dir(git_dir, ['update-ref', 'refs/heads/${base}', head_sha])
513 if r.exit_code != 0 {
514 return error('fast-forward update-ref failed: ${r.output}')
515 }
516 return head_sha
517 }
518 // Use modern merge-tree --write-tree (Git >= 2.38).
519 merge_result :=
520 git.Git.exec_shell('git -C ${git_dir} merge-tree --write-tree ${base_sha} ${head_sha}')
521 if merge_result.exit_code != 0 {
522 return error('merge conflict — cannot auto-merge:\n${merge_result.output}')
523 }
524 tree_sha := merge_result.output.trim_space().split_into_lines().first()
525 if tree_sha == '' {
526 return error('failed to compute merge tree')
527 }
528 commit_sh := 'GIT_AUTHOR_NAME="${author}" GIT_AUTHOR_EMAIL="${author}@gitly" GIT_COMMITTER_NAME="${author}" GIT_COMMITTER_EMAIL="${author}@gitly" git -C ${git_dir} commit-tree ${tree_sha} -p ${base_sha} -p ${head_sha} -m "${shell_escape(message)}"'
529 cr := git.Git.exec_shell(commit_sh)
530 if cr.exit_code != 0 {
531 return error('commit-tree failed: ${cr.output}')
532 }
533 commit_sha := cr.output.trim_space()
534 ur := git.Git.exec_in_dir(git_dir, ['update-ref', 'refs/heads/${base}', commit_sha])
535 if ur.exit_code != 0 {
536 return error('update-ref failed: ${ur.output}')
537 }
538 return commit_sha
539}
540
541// render_inline_comments returns the HTML rows for any line comments
542// attached to a given diff line (matched on file_path, side, line_number).
543fn render_inline_comments(file_path string, dline DiffLine, comments_by_key map[string][]PrReviewCommentWithUser) veb.RawHtml {
544 mut side := ''
545 mut line_no := 0
546 if dline.kind == 'add' {
547 side = 'new'
548 line_no = dline.new_line
549 } else if dline.kind == 'del' {
550 side = 'old'
551 line_no = dline.old_line
552 } else {
553 return veb.RawHtml('')
554 }
555 key := '${file_path}|${side}|${line_no}'
556 list := comments_by_key[key] or { return veb.RawHtml('') }
557 if list.len == 0 {
558 return veb.RawHtml('')
559 }
560 mut out := ''
561 for c in list {
562 body := html_escape_text(c.item.text)
563 username := html_escape_text(c.user.username)
564 rel := html_escape_text(c.item.relative())
565 out += '<tr class="pr-diff__inline-comment"><td colspan="4">' +
566 '<div class="pr-inline-comment">' +
567 '<strong>${username}</strong> <span class="pr-inline-comment__meta">commented ${rel}</span>' +
568 '<p>${body}</p>' + '</div></td></tr>'
569 }
570 return veb.RawHtml(out)
571}
572
573// is_safe_ref does a strict whitelist check for branch names used in shell.
574fn is_safe_ref(name string) bool {
575 if name == '' {
576 return false
577 }
578 for ch in name {
579 if !(ch.is_letter() || ch.is_digit() || ch in [`-`, `_`, `.`, `/`]) {
580 return false
581 }
582 }
583 if name.starts_with('-') || name.contains('..') {
584 return false
585 }
586 return true
587}
588
589fn shell_escape(s string) string {
590 mut out := ''
591 backtick := u8(0x60)
592 for ch in s {
593 if ch == `"` || ch == `\\` || ch == `$` || ch == backtick {
594 out += '\\'
595 }
596 out += ch.ascii_str()
597 }
598 return out
599}
600