gitlyx / pr_routes.v
830 lines · 787 sloc · 27.48 KB · 03ce80574e9d3cb5892de161e92055e76b30af1c
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
9import strings
10
11struct PrWithUser {
12 pr PullRequest
13 user User
14}
15
16struct PrCommentWithUser {
17 item PrComment
18 user User
19}
20
21struct PrReviewWithUser {
22 review PrReview
23 user User
24 comments []PrReviewComment
25}
26
27struct PrTimelineEntry {
28mut:
29 kind string // 'comment' or 'review'
30 created_at int
31 user User
32 comment PrComment
33 review PrReview
34 rcomments []PrReviewCommentWithUser
35}
36
37struct PrReviewCommentWithUser {
38 item PrReviewComment
39 user User
40}
41
42struct PrFileTreeRow {
43 path string
44 name string
45 depth int
46 indent_px int
47 is_dir bool
48 is_new bool
49 is_deleted bool
50 is_renamed bool
51 additions int
52 deletions int
53}
54
55fn render_pr_file_tree(file_tree []PrFileTreeRow) veb.RawHtml {
56 mut out := strings.new_builder(file_tree.len * 160)
57 for row in file_tree {
58 path := html_escape_text(row.path)
59 name := html_escape_text(row.name)
60 indent := row.indent_px
61 if row.is_dir {
62 out.write_string('<button type=button class="r d" q="${path}" style="--i:${indent}px" aria-expanded=true><b></b><span>${name}</span></button>')
63 } else {
64 status := row.status()
65 out.write_string('<a class="r f" href="#diff-${path}" p="${path}" style="--i:${indent}px" title="${path}"><b></b><span>${name}</span><em>${status}</em><small><b>+${row.additions}</b><i>-${row.deletions}</i></small></a>')
66 }
67 }
68 return veb.RawHtml(out.str())
69}
70
71fn (row PrFileTreeRow) status() string {
72 if row.is_new {
73 return 'A'
74 }
75 if row.is_deleted {
76 return 'D'
77 }
78 if row.is_renamed {
79 return 'R'
80 }
81 return 'M'
82}
83
84fn render_pr_file_diff(fd FileDiff, comments_by_key map[string][]PrReviewCommentWithUser, can_comment bool, lang Lang) veb.RawHtml {
85 mut out := strings.new_builder(1024)
86 path := html_escape_text(fd.path)
87 out.write_string('<div class=pr-diff id="diff-${path}" data-diff-path="${path}"><div class=pr-diff__header><span class=pr-diff__path>')
88 if fd.is_renamed && fd.old_path != fd.path {
89 old_path := html_escape_text(fd.old_path)
90 renamed_label := tr_text(lang, 'commit_file_renamed')
91 out.write_string('<span class=pr-diff__tag>${renamed_label}</span><span class=pr-diff__oldpath>${old_path} →</span>')
92 }
93 out.write_string(path)
94 if fd.is_new {
95 new_label := tr_text(lang, 'commit_file_new')
96 out.write_string('<span class="pr-diff__tag pr-diff__tag--new">${new_label}</span>')
97 }
98 if fd.is_deleted {
99 deleted_label := tr_text(lang, 'commit_file_deleted')
100 out.write_string('<span class="pr-diff__tag pr-diff__tag--deleted">${deleted_label}</span>')
101 }
102 out.write_string('</span><span class=pr-diff__counts><span class=pr-diff__add>+${fd.additions}</span><span class=pr-diff__del>-${fd.deletions}</span></span></div>')
103 if fd.is_binary {
104 binary_label := tr_text(lang, 'pr_binary_file')
105 out.write_string('<div class=pr-diff__binary>${binary_label}</div>')
106 } else {
107 out.write_string(pr_diff_table_html(fd, comments_by_key, can_comment))
108 }
109 out.write_string('</div>')
110 return veb.RawHtml(out.str())
111}
112
113fn tr_text(lang Lang, key string) string {
114 return html_escape_text(veb.tr(lang.str(), key).trim_space())
115}
116
117fn build_pr_file_tree_rows(file_diffs []FileDiff) []PrFileTreeRow {
118 mut sorted := file_diffs.clone()
119 sorted.sort(a.path < b.path)
120 mut rows := []PrFileTreeRow{}
121 mut seen_dirs := map[string]bool{}
122 for fd in sorted {
123 parts := fd.path.split('/')
124 if parts.len == 0 {
125 continue
126 }
127 mut current_path := ''
128 if parts.len > 1 {
129 for idx := 0; idx < parts.len - 1; idx++ {
130 part := parts[idx]
131 if part == '' {
132 continue
133 }
134 current_path = if current_path == '' { part } else { '${current_path}/${part}' }
135 if current_path !in seen_dirs {
136 seen_dirs[current_path] = true
137 rows << PrFileTreeRow{
138 path: current_path
139 name: part
140 depth: idx
141 indent_px: 10 + idx * 16
142 is_dir: true
143 }
144 }
145 }
146 }
147 rows << PrFileTreeRow{
148 path: fd.path
149 name: parts[parts.len - 1]
150 depth: parts.len - 1
151 indent_px: 10 + (parts.len - 1) * 16
152 is_new: fd.is_new
153 is_deleted: fd.is_deleted
154 is_renamed: fd.is_renamed
155 additions: fd.additions
156 deletions: fd.deletions
157 }
158 }
159 return rows
160}
161
162// GET /:username/:repo_name/pulls
163@['/:username/:repo_name/pulls']
164pub fn (mut app App) handle_get_repo_pulls(mut ctx Context, username string, repo_name string) veb.Result {
165 return app.repo_pulls(mut ctx, username, repo_name, 'open')
166}
167
168@['/:username/:repo_name/pulls/:tab']
169pub fn (mut app App) repo_pulls(mut ctx Context, username string, repo_name string, tab string) veb.Result {
170 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
171 if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public {
172 return ctx.not_found()
173 }
174 current_tab := if tab in ['open', 'closed', 'merged'] { tab } else { 'open' }
175 status := match current_tab {
176 'closed' { PrStatus.closed }
177 'merged' { PrStatus.merged }
178 else { PrStatus.open }
179 }
180
181 prs := app.find_repo_pull_requests(repo.id, status)
182 mut prs_with_users := []PrWithUser{}
183 for pr in prs {
184 author := app.get_user_by_id(pr.author_id) or { continue }
185 prs_with_users << PrWithUser{
186 pr: pr
187 user: author
188 }
189 }
190 _ := app.get_repo_open_pr_count(repo.id)
191 tab_open_class := if current_tab == 'open' { 'pr-tab pr-tab--active' } else { 'pr-tab' }
192 tab_merged_class := if current_tab == 'merged' { 'pr-tab pr-tab--active' } else { 'pr-tab' }
193 tab_closed_class := if current_tab == 'closed' { 'pr-tab pr-tab--active' } else { 'pr-tab' }
194 tab_title := match current_tab {
195 'closed' { 'Closed pull requests' }
196 'merged' { 'Merged pull requests' }
197 else { 'Open pull requests' }
198 }
199
200 ctx.set_page_title([tab_title, '${repo.user_name}/${repo.name}'])
201 return $veb.html('templates/pulls.html')
202}
203
204// GET /:username/:repo_name/pulls/new
205@['/:username/:repo_name/compare']
206pub fn (mut app App) new_pull_request_form(mut ctx Context, username string, repo_name string) veb.Result {
207 if !ctx.logged_in {
208 return ctx.redirect_to_login()
209 }
210 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
211 if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public {
212 return ctx.not_found()
213 }
214 branches := app.get_all_repo_branches(repo.id)
215 base := if 'base' in ctx.query { ctx.query['base'] } else { repo.primary_branch }
216 head := if 'head' in ctx.query { ctx.query['head'] } else { '' }
217 mut commits := []Commit{}
218 mut file_diffs := []FileDiff{}
219 mut suggested_title := ''
220 mut error_msg := ''
221 mut has_compare := false
222 if head != '' && head != base {
223 has_compare = true
224 if !app.contains_repo_branch(repo.id, head) || !app.contains_repo_branch(repo.id, base) {
225 error_msg = 'Both base and compare branches must exist in this repository.'
226 has_compare = false
227 } else {
228 commits = repo.list_commits_between(base, head)
229 raw_diff := repo.diff_branches(base, head)
230 file_diffs = parse_unified_diff(raw_diff)
231 if commits.len > 0 {
232 suggested_title = commits[0].message
233 }
234 }
235 }
236 ctx.set_page_title(['New pull request', '${repo.user_name}/${repo.name}'])
237 return $veb.html('templates/new/pull.html')
238}
239
240// POST /:username/:repo_name/pulls
241@['/:username/:repo_name/pulls'; post]
242pub fn (mut app App) handle_create_pull_request(mut ctx Context, username string, repo_name string) veb.Result {
243 if !ctx.logged_in {
244 return ctx.redirect_to_login()
245 }
246 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
247 if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public {
248 return ctx.not_found()
249 }
250 title := ctx.form['title']
251 description := ctx.form['description']
252 head := ctx.form['head']
253 base := ctx.form['base']
254 if validation.is_string_empty(title) || validation.is_string_empty(head)
255 || validation.is_string_empty(base) {
256 ctx.error('Title, head and base branches are required')
257 return ctx.redirect('/${username}/${repo_name}/compare?base=${base}&head=${head}')
258 }
259 if head == base {
260 ctx.error('Head and base must differ')
261 return ctx.redirect('/${username}/${repo_name}/compare')
262 }
263 if !app.contains_repo_branch(repo.id, head) || !app.contains_repo_branch(repo.id, base) {
264 ctx.error('Branches not found')
265 return ctx.redirect('/${username}/${repo_name}/compare')
266 }
267 commits := repo.list_commits_between(base, head)
268 if commits.len == 0 {
269 ctx.error('No commits between base and head')
270 return ctx.redirect('/${username}/${repo_name}/compare?base=${base}&head=${head}')
271 }
272 pr_id := app.add_pull_request(repo.id, ctx.user.id, title, description, head, base) or {
273 ctx.error('Could not create pull request')
274 return ctx.redirect('/${username}/${repo_name}/compare')
275 }
276 app.increment_repo_open_prs(repo.id) or { app.info(err.str()) }
277 app.dispatch_webhook(repo.id, 'pr', WebhookPrPayload{
278 action: 'opened'
279 repo: '${username}/${repo_name}'
280 number: pr_id
281 title: title
282 author: ctx.user.username
283 head: head
284 base: base
285 })
286 return ctx.redirect('/${username}/${repo_name}/pull/${pr_id}')
287}
288
289// GET /:username/:repo_name/pull/:id
290@['/:username/:repo_name/pull/:id']
291pub fn (mut app App) pull_request(mut ctx Context, username string, repo_name string, id string) veb.Result {
292 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
293 if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public {
294 return ctx.not_found()
295 }
296 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
297 if pr.repo_id != repo.id {
298 return ctx.not_found()
299 }
300 author := app.get_user_by_id(pr.author_id) or { return ctx.not_found() }
301 commits := repo.list_commits_between(pr.base_branch, pr.head_branch)
302 comments := app.get_pr_comments(pr.id)
303 reviews := app.get_pr_reviews(pr.id)
304 rcomments := app.get_pr_review_comments(pr.id)
305 mut timeline := []PrTimelineEntry{}
306 for c in comments {
307 u := app.get_user_by_id(c.author_id) or { continue }
308 timeline << PrTimelineEntry{
309 kind: 'comment'
310 created_at: c.created_at
311 user: u
312 comment: c
313 }
314 }
315 for r in reviews {
316 u := app.get_user_by_id(r.author_id) or { continue }
317 mut r_comments := []PrReviewCommentWithUser{}
318 for rc in rcomments {
319 if rc.review_id == r.id {
320 uu := app.get_user_by_id(rc.author_id) or { continue }
321 r_comments << PrReviewCommentWithUser{
322 item: rc
323 user: uu
324 }
325 }
326 }
327 timeline << PrTimelineEntry{
328 kind: 'review'
329 created_at: r.created_at
330 user: u
331 review: r
332 rcomments: r_comments
333 }
334 }
335 timeline.sort(a.created_at < b.created_at)
336 is_repo_owner := repo.user_id == ctx.user.id
337 can_merge := is_repo_owner && pr.is_open()
338 can_close := pr.is_open() && (is_repo_owner || pr.author_id == ctx.user.id)
339 can_reopen := pr.is_closed() && (is_repo_owner || pr.author_id == ctx.user.id)
340 ctx.set_page_title(['${pr.title} #${pr.id}', '${repo.user_name}/${repo.name}'])
341 return $veb.html('templates/pull.html')
342}
343
344// GET /:username/:repo_name/pull/:id/files
345@['/:username/:repo_name/pull/:id/files']
346pub fn (mut app App) pull_request_files(mut ctx Context, username string, repo_name string, id string) veb.Result {
347 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
348 if !app.has_user_repo_read_access(ctx, ctx.user.id, repo.id) && !repo.is_public {
349 return ctx.not_found()
350 }
351 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
352 if pr.repo_id != repo.id {
353 return ctx.not_found()
354 }
355 author := app.get_user_by_id(pr.author_id) or { return ctx.not_found() }
356 raw_diff := repo.diff_branches(pr.base_branch, pr.head_branch)
357 file_diffs := parse_unified_diff(raw_diff)
358 mut all_adds := 0
359 mut all_dels := 0
360 for fd in file_diffs {
361 all_adds += fd.additions
362 all_dels += fd.deletions
363 }
364 file_tree := build_pr_file_tree_rows(file_diffs)
365 rcomments := app.get_pr_review_comments(pr.id)
366 mut comments_by_key := map[string][]PrReviewCommentWithUser{}
367 for rc in rcomments {
368 u := app.get_user_by_id(rc.author_id) or { continue }
369 key := '${rc.file_path}|${rc.side}|${rc.line_number}'
370 comments_by_key[key] << PrReviewCommentWithUser{
371 item: rc
372 user: u
373 }
374 }
375 can_comment := ctx.logged_in && pr.is_open()
376 line_comment_placeholder := veb.tr(ctx.lang.str(), 'pr_line_comment_placeholder').trim_space()
377 ctx.set_page_title(['${pr.title} #${pr.id}', 'Files changed', '${repo.user_name}/${repo.name}'])
378 return $veb.html('templates/pull_files.html')
379}
380
381// POST /:username/:repo_name/pull/:id/comments
382@['/:username/:repo_name/pull/:id/comments'; post]
383pub fn (mut app App) handle_add_pr_comment(mut ctx Context, username string, repo_name string, id string) veb.Result {
384 if !ctx.logged_in {
385 return ctx.redirect_to_login()
386 }
387 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
388 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
389 if pr.repo_id != repo.id {
390 return ctx.not_found()
391 }
392 text := ctx.form['text']
393 if validation.is_string_empty(text) {
394 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
395 }
396 app.add_pr_comment(pr.id, ctx.user.id, text) or {
397 ctx.error('Could not add comment')
398 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
399 }
400 app.increment_pr_comments(pr.id) or { app.info(err.str()) }
401 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
402}
403
404// POST /:username/:repo_name/pull/:id/review
405@['/:username/:repo_name/pull/:id/review'; post]
406pub fn (mut app App) handle_submit_review(mut ctx Context, username string, repo_name string, id string) veb.Result {
407 if !ctx.logged_in {
408 return ctx.redirect_to_login()
409 }
410 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
411 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
412 if pr.repo_id != repo.id {
413 return ctx.not_found()
414 }
415 body := ctx.form['body']
416 state_str := ctx.form['state']
417 state := match state_str {
418 'approved' { 1 }
419 'changes_requested' { 2 }
420 else { 0 }
421 }
422
423 review_id := app.add_pr_review(pr.id, ctx.user.id, state, body) or {
424 ctx.error('Could not submit review')
425 return ctx.redirect('/${username}/${repo_name}/pull/${id}/files')
426 }
427 // Attach pending line comments from form (file_path|side|line — text)
428 for key, val in ctx.form {
429 if !key.starts_with('rc::') {
430 continue
431 }
432 text := val.trim_space()
433 if text == '' {
434 continue
435 }
436 // rc::file::side::line
437 parts := key[4..].split('::')
438 if parts.len < 3 {
439 continue
440 }
441 file_path := parts[0]
442 side := parts[1]
443 line_no := parts[2].int()
444 app.add_pr_review_comment(pr.id, ctx.user.id, review_id, file_path, line_no, side, text) or {
445 continue
446 }
447 }
448 if body != '' {
449 app.increment_pr_comments(pr.id) or {}
450 }
451 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
452}
453
454// POST /:username/:repo_name/pull/:id/line-comment
455@['/:username/:repo_name/pull/:id/line-comment'; post]
456pub fn (mut app App) handle_add_line_comment(mut ctx Context, username string, repo_name string, id string) veb.Result {
457 if !ctx.logged_in {
458 return ctx.redirect_to_login()
459 }
460 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
461 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
462 if pr.repo_id != repo.id {
463 return ctx.not_found()
464 }
465 file_path := ctx.form['file_path']
466 side := ctx.form['side']
467 line_no := ctx.form['line_number'].int()
468 text := ctx.form['text']
469 if validation.is_string_empty(text) || validation.is_string_empty(file_path) {
470 return ctx.redirect('/${username}/${repo_name}/pull/${id}/files')
471 }
472 app.add_pr_review_comment(pr.id, ctx.user.id, 0, file_path, line_no, side, text) or {
473 ctx.error('Could not add line comment')
474 return ctx.redirect('/${username}/${repo_name}/pull/${id}/files')
475 }
476 return ctx.redirect('/${username}/${repo_name}/pull/${id}/files#${file_path}-${side}-${line_no}')
477}
478
479// POST /:username/:repo_name/pull/:id/close
480@['/:username/:repo_name/pull/:id/close'; post]
481pub fn (mut app App) handle_close_pr(mut ctx Context, username string, repo_name string, id string) veb.Result {
482 if !ctx.logged_in {
483 return ctx.redirect_to_login()
484 }
485 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
486 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
487 if pr.repo_id != repo.id {
488 return ctx.not_found()
489 }
490 can_close := repo.user_id == ctx.user.id || pr.author_id == ctx.user.id
491 if !can_close {
492 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
493 }
494 if !pr.is_open() {
495 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
496 }
497 app.set_pr_status(pr.id, .closed) or {
498 ctx.error('Could not close PR')
499 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
500 }
501 app.decrement_repo_open_prs(repo.id) or {}
502 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
503}
504
505// POST /:username/:repo_name/pull/:id/reopen
506@['/:username/:repo_name/pull/:id/reopen'; post]
507pub fn (mut app App) handle_reopen_pr(mut ctx Context, username string, repo_name string, id string) veb.Result {
508 if !ctx.logged_in {
509 return ctx.redirect_to_login()
510 }
511 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
512 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
513 if pr.repo_id != repo.id {
514 return ctx.not_found()
515 }
516 can_reopen := repo.user_id == ctx.user.id || pr.author_id == ctx.user.id
517 if !can_reopen {
518 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
519 }
520 if !pr.is_closed() {
521 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
522 }
523 if !app.contains_repo_branch(repo.id, pr.head_branch)
524 || !app.contains_repo_branch(repo.id, pr.base_branch) {
525 ctx.error('Cannot reopen: head or base branch is missing')
526 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
527 }
528 app.set_pr_status(pr.id, .open) or {
529 ctx.error('Could not reopen PR')
530 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
531 }
532 app.increment_repo_open_prs(repo.id) or {}
533 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
534}
535
536// POST /:username/:repo_name/pull/:id/merge
537@['/:username/:repo_name/pull/:id/merge'; post]
538pub fn (mut app App) handle_merge_pr(mut ctx Context, username string, repo_name string, id string) veb.Result {
539 if !ctx.logged_in {
540 return ctx.redirect_to_login()
541 }
542 mut repo := app.find_repo_by_name_and_username(repo_name, username) or {
543 return ctx.not_found()
544 }
545 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
546 if pr.repo_id != repo.id {
547 return ctx.not_found()
548 }
549 if repo.user_id != ctx.user.id {
550 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
551 }
552 if !pr.is_open() {
553 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
554 }
555 merge_message := 'Merge pull request #${pr.id} from ${pr.head_branch}\n\n${pr.title}'
556 merge_hash := merge_branches_in_bare(repo, pr.base_branch, pr.head_branch, ctx.user.username,
557 merge_message) or {
558 ctx.error('Merge failed: ${err}')
559 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
560 }
561 app.complete_pr_merge(repo, pr, merge_hash) or {
562 ctx.error('Merged but failed to update PR record')
563 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
564 }
565 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
566}
567
568// POST /:username/:repo_name/pull/:id/squash
569@['/:username/:repo_name/pull/:id/squash'; post]
570pub fn (mut app App) handle_squash_pr(mut ctx Context, username string, repo_name string, id string) veb.Result {
571 if !ctx.logged_in {
572 return ctx.redirect_to_login()
573 }
574 mut repo := app.find_repo_by_name_and_username(repo_name, username) or {
575 return ctx.not_found()
576 }
577 pr := app.find_pull_request_by_id(id.int()) or { return ctx.not_found() }
578 if pr.repo_id != repo.id {
579 return ctx.not_found()
580 }
581 if repo.user_id != ctx.user.id {
582 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
583 }
584 if !pr.is_open() {
585 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
586 }
587 merge_message := 'Squash pull request #${pr.id} from ${pr.head_branch}\n\n${pr.title}'
588 merge_hash := squash_branches_in_bare(repo, pr.base_branch, pr.head_branch, ctx.user.username,
589 merge_message) or {
590 ctx.error('Squash merge failed: ${err}')
591 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
592 }
593 app.complete_pr_merge(repo, pr, merge_hash) or {
594 ctx.error('Merged but failed to update PR record')
595 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
596 }
597 return ctx.redirect('/${username}/${repo_name}/pull/${id}')
598}
599
600fn (mut app App) complete_pr_merge(repo Repo, pr PullRequest, merge_hash string) ! {
601 app.set_pr_merged(pr.id, merge_hash)!
602 app.decrement_repo_open_prs(repo.id) or {}
603 app.update_repo_branch_after_change(repo.id, pr.base_branch) or {
604 app.warn('Failed to update repo after merge: ${err}')
605 }
606 app.delete_repository_files_in_branch(repo.id, pr.base_branch) or {}
607}
608
609// User-scoped PR list
610@['/:username/pulls']
611pub fn (mut app App) handle_get_user_pulls(mut ctx Context, username string) veb.Result {
612 if !ctx.logged_in {
613 return ctx.not_found()
614 }
615 exists, user := app.check_username(username)
616 if !exists {
617 return ctx.not_found()
618 }
619 mut prs := app.find_user_pull_requests(user.id)
620 mut prs_with_repo := []PullRequest{}
621 for mut pr in prs {
622 r := app.find_repo_by_id(pr.repo_id) or { continue }
623 pr.repo_author = r.user_name
624 pr.repo_name = r.name
625 prs_with_repo << pr
626 }
627 ctx.set_page_title(['Pull requests', user.username])
628 return $veb.html('templates/user_pulls.html')
629}
630
631// --- git helpers ---
632
633// list_commits_between returns commits in head not in base.
634fn (r Repo) list_commits_between(base string, head string) []Commit {
635 if base == '' || head == '' {
636 return []Commit{}
637 }
638 if !is_safe_ref(base) || !is_safe_ref(head) {
639 return []Commit{}
640 }
641 out :=
642 r.git('log ${base}..${head} --pretty=format:%h${log_field_separator}%aE${log_field_separator}%cD${log_field_separator}%s${log_field_separator}%aN')
643 mut commits := []Commit{}
644 for line in out.split_into_lines() {
645 args := line.split(log_field_separator)
646 if args.len < 5 {
647 continue
648 }
649 date := time.parse_rfc2822(args[2]) or { time.now() }
650 commits << Commit{
651 hash: args[0]
652 author: args[4]
653 message: args[3]
654 created_at: int(date.unix())
655 author_id: 0
656 }
657 }
658 return commits
659}
660
661// diff_branches returns the unified diff between base and head.
662fn (r Repo) diff_branches(base string, head string) string {
663 if base == '' || head == '' {
664 return ''
665 }
666 if !is_safe_ref(base) || !is_safe_ref(head) {
667 return ''
668 }
669 return r.git('diff --no-color ${base}...${head}')
670}
671
672// merge_branches_in_bare performs a merge inside a bare repo using
673// git merge-tree to compute the resulting tree, then commit-tree
674// and update-ref to advance the base branch. Returns the merge commit hash.
675fn merge_branches_in_bare(repo Repo, base string, head string, author string, message string) !string {
676 if !is_safe_ref(base) || !is_safe_ref(head) {
677 return error('invalid branch name')
678 }
679 git_dir := repo.git_dir
680 base_sha := git_rev_parse(git_dir, base)!
681 head_sha := git_rev_parse(git_dir, head)!
682 // Try fast-forward first: if base is an ancestor of head, fast-forward.
683 if git_is_ancestor(git_dir, base_sha, head_sha) {
684 update_branch_ref(git_dir, base, head_sha)!
685 return head_sha
686 }
687 // Use modern merge-tree --write-tree (Git >= 2.38).
688 tree_sha := git_merge_tree(git_dir, base_sha, head_sha)!
689 commit_sha := git_commit_tree(git_dir, tree_sha, [base_sha, head_sha], author, message)!
690 update_branch_ref(git_dir, base, commit_sha)!
691 return commit_sha
692}
693
694// squash_branches_in_bare computes the merge result and writes one new commit
695// on the base branch with only the old base commit as its parent.
696fn squash_branches_in_bare(repo Repo, base string, head string, author string, message string) !string {
697 if !is_safe_ref(base) || !is_safe_ref(head) {
698 return error('invalid branch name')
699 }
700 git_dir := repo.git_dir
701 base_sha := git_rev_parse(git_dir, base)!
702 head_sha := git_rev_parse(git_dir, head)!
703 tree_sha := git_merge_tree(git_dir, base_sha, head_sha)!
704 commit_sha := git_commit_tree(git_dir, tree_sha, [base_sha], author, message)!
705 update_branch_ref(git_dir, base, commit_sha)!
706 return commit_sha
707}
708
709fn git_rev_parse(git_dir string, ref_name string) !string {
710 r := git.Git.exec_in_dir(git_dir, ['rev-parse', ref_name])
711 if r.exit_code != 0 {
712 return error('branch refs missing: ${r.output}')
713 }
714 sha := r.output.trim_space()
715 if sha == '' {
716 return error('branch refs missing')
717 }
718 return sha
719}
720
721fn git_is_ancestor(git_dir string, ancestor string, descendant string) bool {
722 r := git.Git.exec_in_dir(git_dir, ['merge-base', '--is-ancestor', ancestor, descendant])
723 return r.exit_code == 0
724}
725
726fn git_merge_tree(git_dir string, base_sha string, head_sha string) !string {
727 r := git.Git.exec_in_dir(git_dir, ['merge-tree', '--write-tree', base_sha, head_sha])
728 if r.exit_code != 0 {
729 return error('merge conflict: cannot auto-merge:\n${r.output}')
730 }
731 lines := r.output.trim_space().split_into_lines()
732 if lines.len == 0 || lines[0] == '' {
733 return error('failed to compute merge tree')
734 }
735 return lines[0]
736}
737
738fn git_commit_tree(git_dir string, tree_sha string, parents []string, author string, message string) !string {
739 mut args := ['commit-tree', tree_sha]
740 for parent in parents {
741 args << ['-p', parent]
742 }
743 args << ['-m', message]
744 env := {
745 'GIT_AUTHOR_NAME': author
746 'GIT_AUTHOR_EMAIL': '${author}@gitly'
747 'GIT_COMMITTER_NAME': author
748 'GIT_COMMITTER_EMAIL': '${author}@gitly'
749 }
750 r := git.Git.exec_in_dir_with_env(git_dir, args, env)
751 if r.exit_code != 0 {
752 return error('commit-tree failed: ${r.output}')
753 }
754 commit_sha := r.output.trim_space()
755 if commit_sha == '' {
756 return error('commit-tree produced no commit')
757 }
758 return commit_sha
759}
760
761fn update_branch_ref(git_dir string, branch string, commit_sha string) ! {
762 r := git.Git.exec_in_dir(git_dir, ['update-ref', 'refs/heads/${branch}', commit_sha])
763 if r.exit_code != 0 {
764 return error('update-ref failed: ${r.output}')
765 }
766}
767
768fn pr_diff_table_html(fd FileDiff, comments_by_key map[string][]PrReviewCommentWithUser, can_comment bool) string {
769 mut out := strings.new_builder(1024)
770 out.write_string('<div class=pr-diff__table>')
771 for hunk in fd.hunks {
772 out.write_string(diff_hunk_header_html(hunk.header))
773 for dline in hunk.lines {
774 attrs := if can_comment && dline.kind != 'context' {
775 ' s=${dline.compact_side()} l=${dline.effective_line()}'
776 } else {
777 ''
778 }
779 out.write_string(diff_line_row_html_with_attrs(fd.path, dline, attrs))
780 out.write_string(inline_comments_html(fd.path, dline, comments_by_key))
781 }
782 }
783 out.write_string('</div>')
784 return out.str()
785}
786
787// inline_comments_html returns any line comments attached to a given diff line,
788// matched on file_path, side, and line_number.
789fn inline_comments_html(file_path string, dline DiffLine, comments_by_key map[string][]PrReviewCommentWithUser) string {
790 mut side := ''
791 mut line_no := 0
792 if dline.kind == 'add' {
793 side = 'new'
794 line_no = dline.new_line
795 } else if dline.kind == 'del' {
796 side = 'old'
797 line_no = dline.old_line
798 } else {
799 return ''
800 }
801 key := '${file_path}|${side}|${line_no}'
802 list := comments_by_key[key] or { return '' }
803 if list.len == 0 {
804 return ''
805 }
806 mut out := ''
807 for c in list {
808 body := html_escape_text(c.item.text)
809 username := html_escape_text(c.user.username)
810 rel := html_escape_text(c.item.relative())
811 out += '<p class=n><b>${username}</b> <i>commented ${rel}</i><br><s>${body}</s></p>'
812 }
813 return out
814}
815
816// is_safe_ref does a strict whitelist check for branch names used in shell.
817fn is_safe_ref(name string) bool {
818 if name == '' {
819 return false
820 }
821 for ch in name {
822 if !(ch.is_letter() || ch.is_digit() || ch in [`-`, `_`, `.`, `/`]) {
823 return false
824 }
825 }
826 if name.starts_with('-') || name.contains('..') {
827 return false
828 }
829 return true
830}
831