package main
import (
"bytes"
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/gitexec"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/i18n"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/store"
)
// mrTitleMaxLen/mrTextMaxLen match the form's client-side maxlength
// attributes — enforced again server-side since a direct POST bypasses
// those.
const (
mrTitleMaxLen = 200
mrTextMaxLen = 4000
)
// mrView adds a display-only translated status label to a store.MergeRequest.
type mrView struct {
store.MergeRequest
StatusLabel string
}
func newMRView(lang i18n.Lang, mr store.MergeRequest) mrView {
return mrView{MergeRequest: mr, StatusLabel: mrStatusLabel(lang, mr.Status)}
}
func mrStatusLabel(lang i18n.Lang, status store.MRStatus) string {
switch status {
case store.MROpen:
return i18n.T(lang, "mr.status_open")
case store.MRMerged:
return i18n.T(lang, "mr.status_merged")
case store.MRClosed:
return i18n.T(lang, "mr.status_closed")
default:
return string(status)
}
}
func containsBranch(branches []string, name string) bool {
for _, b := range branches {
if b == name {
return true
}
}
return false
}
var mrListTpl = newTpl("mr-list", `
{{.Flash}}
<div class="gf-crumbs"><a href="/r/{{.Repo.Name}}">{{.Repo.Name}}</a><span class="sep">/</span>{{t .Lang "mr.list_title"}}</div>
<div class="gf-page-head">
<h1>{{t .Lang "mr.list_title"}}</h1>
{{if .CanWrite}}<a href="/repo-mr-new/{{.Repo.Name}}" class="gf-btn">{{icon "plus"}} {{t .Lang "mr.new"}}</a>{{end}}
</div>
<div class="gf-card">
{{range .MRs}}
<a class="mr-row" href="/repo-mr/{{$.Repo.Name}}?number={{.Number}}">
<div class="mr-status-dot {{.Status}}"></div>
<div class="mr-main">
<div class="title">{{.Title}}</div>
<div class="meta">#{{.Number}} {{t $.Lang "mr.by"}} {{.Author}} · <span class="branches">{{.SourceBranch}} → {{.TargetBranch}}</span></div>
</div>
<span class="badge {{.Status}}">{{.StatusLabel}}</span>
</a>
{{else}}
<div class="gf-empty">
{{icon "branch"}}
<p><strong>{{t .Lang "mr.empty_title"}}</strong></p>
{{if .CanWrite}}<p class="muted">{{t .Lang "mr.empty_hint"}}</p>{{end}}
</div>
{{end}}
</div>
`)
func (s *server) handleMRList(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
repo, err := s.ops.GetRepo(name)
if err != nil || !s.canView(r, repo) {
http.NotFound(w, r)
return
}
mrs, err := s.ops.ListMergeRequests(name)
if err != nil {
s.serverError(w, r, err)
return
}
views := make([]mrView, len(mrs))
for i, mr := range mrs {
views[i] = newMRView(lang, mr)
}
_, canWrite := s.canWrite(r, name)
var buf bytes.Buffer
_ = mrListTpl.Execute(&buf, struct {
Repo store.Repo
Lang string
MRs []mrView
CanWrite bool
Flash template.HTML
}{repo, string(lang), views, canWrite, flash(r)})
s.render(w, r, name+" — "+i18n.T(lang, "mr.list_title"), "home", template.HTML(buf.String()))
}
var mrNewTpl = newTpl("mr-new", `
{{.Flash}}
<div class="gf-crumbs">
<a href="/r/{{.Repo.Name}}">{{.Repo.Name}}</a><span class="sep">/</span>
<a href="/repo-mrs/{{.Repo.Name}}">{{t .Lang "mr.list_title"}}</a><span class="sep">/</span>
{{t .Lang "mr.new"}}
</div>
<div class="gf-page-head"><h1>{{t .Lang "mr.new"}}</h1></div>
{{if lt (len .Branches) 2}}
<div class="gf-card">
<div class="gf-empty">
{{icon "branch"}}
<p><strong>{{t .Lang "mr.need_two_branches"}}</strong></p>
</div>
</div>
{{else}}
<form class="card" method="post" action="/repo-mr-new/{{.Repo.Name}}">
<label>{{t .Lang "mr.branches_label"}}</label>
<div class="branch-picker">
<select name="source" required>
{{range .Branches}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
<span class="arrow">{{icon "arrow"}}</span>
<select name="target" required>
{{range .Branches}}<option value="{{.}}" {{if eq . $.DefaultBranch}}selected{{end}}>{{.}}</option>{{end}}
</select>
</div>
<label>{{t .Lang "mr.title_label"}}</label>
<input name="title" required maxlength="200">
<label>{{t .Lang "mr.description_label"}}</label>
<textarea name="description" maxlength="4000"></textarea>
<div style="display:flex; gap:0.6rem; justify-content:flex-end;">
<a href="/repo-mrs/{{.Repo.Name}}" class="gf-btn">{{t .Lang "mr.cancel"}}</a>
<button type="submit">{{t .Lang "mr.open"}}</button>
</div>
</form>
{{end}}
`)
func (s *server) handleMRNewForm(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
repo, err := s.ops.GetRepo(name)
if err != nil {
http.NotFound(w, r)
return
}
if _, ok := s.canWrite(r, name); !ok {
http.NotFound(w, r)
return
}
branches, err := s.ops.ListBranches(name)
if err != nil {
s.serverError(w, r, err)
return
}
defaultBranch, _, _ := s.ops.GetRepoBranch(name)
var buf bytes.Buffer
_ = mrNewTpl.Execute(&buf, struct {
Repo store.Repo
Lang string
Branches []string
DefaultBranch string
Flash template.HTML
}{repo, string(lang), branches, defaultBranch, flash(r)})
s.render(w, r, i18n.T(lang, "mr.new")+" — "+name, "home", template.HTML(buf.String()))
}
func (s *server) handleMRCreate(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
actor, ok := s.canWrite(r, name)
if !ok {
http.NotFound(w, r)
return
}
if _, err := s.ops.GetRepo(name); err != nil {
http.NotFound(w, r)
return
}
title := strings.TrimSpace(r.FormValue("title"))
description := strings.TrimSpace(r.FormValue("description"))
source := strings.TrimSpace(r.FormValue("source"))
target := strings.TrimSpace(r.FormValue("target"))
newPath := "/repo-mr-new/" + name
// The form's maxlength attributes are client-side only — a direct POST
// bypasses them, so the same limits are enforced here too.
if title == "" || source == "" || target == "" || source == target ||
len(title) > mrTitleMaxLen || len(description) > mrTextMaxLen {
redirectWithMsg(w, r, newPath, i18n.T(lang, "mr.msg_invalid"), true)
return
}
branches, err := s.ops.ListBranches(name)
if err != nil {
s.serverError(w, r, err)
return
}
if !containsBranch(branches, source) || !containsBranch(branches, target) {
redirectWithMsg(w, r, newPath, i18n.T(lang, "mr.msg_invalid"), true)
return
}
mr, err := s.ops.CreateMergeRequest(name, title, description, actor, source, target)
if err != nil {
s.serverError(w, r, err)
return
}
redirectWithMsg(w, r, fmt.Sprintf("/repo-mr/%s?number=%d", name, mr.Number), i18n.T(lang, "mr.msg_created"), false)
}
var mrDetailTpl = newTpl("mr-detail", `
{{.Flash}}
<div class="gf-crumbs">
<a href="/r/{{.Repo.Name}}">{{.Repo.Name}}</a><span class="sep">/</span>
<a href="/repo-mrs/{{.Repo.Name}}">{{t .Lang "mr.list_title"}}</a><span class="sep">/</span>
#{{.MR.Number}}
</div>
<div class="gf-card">
<div class="mr-detail-head">
<div class="titlebar">
<span class="badge {{.MR.Status}}">{{.MR.StatusLabel}}</span>
<h2>{{.MR.Title}} <span class="num">#{{.MR.Number}}</span></h2>
</div>
<div class="branches"><code>{{.MR.SourceBranch}}</code> {{icon "arrow"}} <code>{{.MR.TargetBranch}}</code></div>
{{if .MR.Description}}<p class="desc">{{.MR.Description}}</p>{{end}}
<p class="meta">{{t .Lang "mr.opened_by"}} {{.MR.Author}} · {{.MR.CreatedAt.Local.Format "2006-01-02 15:04"}}</p>
</div>
{{if eq (print .MR.Status) "open"}}
{{if not .BranchesFound}}
<div class="merge-panel conflict"><div class="msg"><strong>{{t .Lang "mr.branch_missing"}}</strong></div></div>
{{else if .Mergeable.Clean}}
<div class="merge-panel clean">
<div class="msg"><strong>{{t .Lang "mr.clean_title"}}</strong></div>
{{if $.CanWrite}}
<div style="display:flex; gap:0.6rem;">
<form method="post" action="/repo-mr-close/{{$.Repo.Name}}"><input type="hidden" name="number" value="{{.MR.Number}}"><button type="submit">{{t $.Lang "mr.close"}}</button></form>
<form method="post" action="/repo-mr-merge/{{$.Repo.Name}}"><input type="hidden" name="number" value="{{.MR.Number}}"><button type="submit" class="primary">{{t $.Lang "mr.merge"}}</button></form>
</div>
{{end}}
</div>
{{else}}
<div class="merge-panel conflict">
<div class="msg">
<strong>{{t .Lang "mr.conflict_title"}}</strong>
<span class="files">{{range .Mergeable.ConflictFiles}}{{.}} {{end}}</span>
</div>
{{if $.CanWrite}}
<form method="post" action="/repo-mr-close/{{$.Repo.Name}}"><input type="hidden" name="number" value="{{.MR.Number}}"><button type="submit">{{t $.Lang "mr.close"}}</button></form>
{{end}}
</div>
{{end}}
{{else if eq (print .MR.Status) "merged"}}
<div class="merge-panel clean"><div class="msg"><strong>{{t .Lang "mr.merged_note"}} {{.MR.MergedBy}}</strong> <span class="files"><a href="/repo-commit/{{.Repo.Name}}?hash={{.MR.MergeCommit}}">{{.MR.MergeCommit}}</a></span></div></div>
{{else}}
<div class="merge-panel conflict"><div class="msg"><strong>{{t .Lang "mr.closed_note"}}</strong></div></div>
{{end}}
</div>
{{if .BranchesFound}}
<div class="gf-card">
<div class="gf-diffstat-head">
<span>{{len .Files}} {{t .Lang "repo.files_changed"}}</span>
<span class="totals"><span class="add">+{{.TotalAdd}}</span> <span class="del">−{{.TotalDel}}</span></span>
</div>
<div>
{{range .Files}}
<div class="gf-diffstat-row">
<span class="badge {{.Class}}">{{.Letter}}</span>
<span class="path">{{.Path}}</span>
{{if .Binary}}<span class="stat muted">{{t $.Lang "repo.binary_file"}}</span>{{else}}<span class="stat"><span class="add">+{{.Insertions}}</span> <span class="del">−{{.Deletions}}</span></span>{{end}}
</div>
{{end}}
</div>
</div>
{{if .DiffSections}}
<div class="gf-card">
{{range .DiffSections}}
<div class="gf-diff-file">
<div class="gf-diff-file-head">{{.Path}}</div>
<div class="gf-diff">{{range .Lines}}<span class="line {{.Class}}">{{.Text}}</span>
{{end}}</div>
</div>
{{end}}
</div>
{{end}}
{{if .Truncated}}<p class="muted" style="text-align:center;">{{t .Lang "repo.diff_truncated"}}</p>{{end}}
{{end}}
<div class="gf-card">
{{range .Comments}}
<div class="comment">
<div class="avatar">{{.AuthorInitials}}</div>
<div class="body">
<div class="head"><span class="author">{{.Author}}</span><span class="when">{{.CreatedAt.Local.Format "2006-01-02 15:04"}}</span></div>
<div class="text">{{.Body}}</div>
</div>
</div>
{{end}}
{{if .LoggedIn}}
<form class="comment-form" method="post" action="/repo-mr-comment/{{.Repo.Name}}">
<input type="hidden" name="number" value="{{.MR.Number}}">
<textarea name="body" required maxlength="4000" placeholder="{{t .Lang "mr.comment_placeholder"}}"></textarea>
<div class="row"><button type="submit">{{t .Lang "mr.comment_submit"}}</button></div>
</form>
{{end}}
</div>
`)
func (s *server) handleMRDetail(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
repo, err := s.ops.GetRepo(name)
if err != nil || !s.canView(r, repo) {
http.NotFound(w, r)
return
}
number, err := strconv.Atoi(r.URL.Query().Get("number"))
if err != nil {
http.NotFound(w, r)
return
}
mr, err := s.ops.GetMergeRequest(name, number)
if err != nil {
http.NotFound(w, r)
return
}
files, diff, truncated, found, err := s.ops.MergeRequestDiff(name, number)
if err != nil {
s.serverError(w, r, err)
return
}
var mergeable gitexec.MergeResult
if found && mr.Status == store.MROpen {
mergeable, err = s.ops.CheckMergeRequestMergeable(name, number)
if err != nil {
s.serverError(w, r, err)
return
}
}
comments, err := s.ops.ListMRComments(name, number)
if err != nil {
s.serverError(w, r, err)
return
}
commentViews := make([]mrCommentView, len(comments))
for i, c := range comments {
commentViews[i] = mrCommentView{MRComment: c, AuthorInitials: initials(c.Author)}
}
views := make([]diffFileView, len(files))
var totalAdd, totalDel int
for i, f := range files {
views[i] = newDiffFileView(f)
totalAdd += f.Insertions
totalDel += f.Deletions
}
_, canWrite := s.canWrite(r, name)
_, loggedIn := s.currentSession(r)
var buf bytes.Buffer
_ = mrDetailTpl.Execute(&buf, struct {
Repo store.Repo
Lang string
MR mrView
BranchesFound bool
Mergeable gitexec.MergeResult
Files []diffFileView
TotalAdd int
TotalDel int
DiffSections []diffFileSection
Truncated bool
Comments []mrCommentView
CanWrite bool
LoggedIn bool
Flash template.HTML
}{repo, string(lang), newMRView(lang, mr), found, mergeable, views, totalAdd, totalDel, classifyDiff(diff), truncated, commentViews, canWrite, loggedIn, flash(r)})
s.render(w, r, fmt.Sprintf("#%d %s — %s", mr.Number, mr.Title, name), "home", template.HTML(buf.String()))
}
type mrCommentView struct {
store.MRComment
AuthorInitials string
}
func (s *server) handleMRMerge(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
actor, ok := s.canWrite(r, name)
if !ok {
http.NotFound(w, r)
return
}
number, err := strconv.Atoi(r.FormValue("number"))
if err != nil {
http.NotFound(w, r)
return
}
back := fmt.Sprintf("/repo-mr/%s?number=%d", name, number)
_, result, err := s.ops.MergeMergeRequest(name, number, actor)
if err != nil {
s.serverError(w, r, err)
return
}
if !result.Clean {
redirectWithMsg(w, r, back, i18n.T(lang, "mr.msg_conflict"), true)
return
}
redirectWithMsg(w, r, back, i18n.T(lang, "mr.msg_merged"), false)
}
func (s *server) handleMRClose(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
if _, ok := s.canWrite(r, name); !ok {
http.NotFound(w, r)
return
}
number, err := strconv.Atoi(r.FormValue("number"))
if err != nil {
http.NotFound(w, r)
return
}
back := fmt.Sprintf("/repo-mr/%s?number=%d", name, number)
if err := s.ops.CloseMergeRequest(name, number); err != nil {
s.serverError(w, r, err)
return
}
redirectWithMsg(w, r, back, i18n.T(lang, "mr.msg_closed"), false)
}
func (s *server) handleMRComment(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
sess, ok := s.currentSession(r)
if !ok {
http.NotFound(w, r)
return
}
repo, err := s.ops.GetRepo(name)
if err != nil || !s.canView(r, repo) {
http.NotFound(w, r)
return
}
number, err := strconv.Atoi(r.FormValue("number"))
if err != nil {
http.NotFound(w, r)
return
}
back := fmt.Sprintf("/repo-mr/%s?number=%d", name, number)
body := strings.TrimSpace(r.FormValue("body"))
if body == "" || len(body) > mrTextMaxLen {
redirectWithMsg(w, r, back, i18n.T(lang, "mr.msg_comment_empty"), true)
return
}
if _, err := s.ops.AddMRComment(name, number, sess.Principal, body); err != nil {
s.serverError(w, r, err)
return
}
redirectWithMsg(w, r, back, i18n.T(lang, "mr.msg_commented"), false)
}