Gitfed
bastien-mrq/gitfed / cmd / gitfed-web / handlers_merge_requests.go
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)
}