CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7755561..8e9a55e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 0.10.0
+
+- Added merge requests: propose merging one branch into another within a repo, with a live diff (always computed from the branches' current tips, never a stale snapshot), a discussion thread, and a one-click merge that creates a real merge commit. Conflicts are detected up front and block the merge with the specific files listed, instead of silently failing — resolve locally, push, and the merge request picks up the new state automatically. Merging never touches git's wire protocol: it's an authenticated web action gated by the same write-access check as a `git push`, executed server-side in a throwaway worktree so it can never corrupt a real branch ref, with an atomic compare-and-swap update so a concurrent push can't be silently discarded.
+
## 0.9.8
- Added `deploy/backup.sh`: snapshots the whole `gitfed-data` PVC (bbolt store, CA key, host key, bare repos) to a timestamped tarball downloaded off the VPS, with automatic pruning. Previously the only documented backup procedure was "copy the whole PVC by hand." Restore procedure and a throwaway `deploy/k8s/restore-pod.yaml` documented in `deploy/k8s/README.md`.
cmd/gitfed-web/handlers_merge_requests.go
diff --git a/cmd/gitfed-web/handlers_merge_requests.go b/cmd/gitfed-web/handlers_merge_requests.go
new file mode 100644
index 0000000..3998338
--- /dev/null
+++ b/cmd/gitfed-web/handlers_merge_requests.go
@@ -0,0 +1,487 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "gitfed/internal/gitexec"
+ "gitfed/internal/i18n"
+ "gitfed/internal/store"
+)
+
+// 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
+
+ if title == "" || source == "" || target == "" || source == target {
+ 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 == "" {
+ 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)
+}
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go
index c19fe80..eb3a5f5 100644
--- a/cmd/gitfed-web/handlers_repo.go
+++ b/cmd/gitfed-web/handlers_repo.go
@@ -40,6 +40,19 @@ func (s *server) canAdminister(r *http.Request, repoName string) (string, bool)
return sess.Principal, err == nil && allowed
}
+// canWrite reports whether the current session may push to repo — the same
+// bar for opening or merging a merge request, since either is something a
+// write collaborator could already do directly over SSH (clone, merge
+// locally, push back).
+func (s *server) canWrite(r *http.Request, repoName string) (string, bool) {
+ sess, ok := s.currentSession(r)
+ if !ok {
+ return "", false
+ }
+ _, allowed, err := s.ops.CheckAccess(repoName, sess.Principal, store.RoleWrite)
+ return sess.Principal, err == nil && allowed
+}
+
// crumb is one clickable segment of an in-repo path breadcrumb.
type crumb struct {
Name, Path string
@@ -109,6 +122,7 @@ var repoTpl = newTpl("repo", `
<button type="button" class="linklike" data-copy="{{.CloneURL}}" title="{{t .Lang "repo.copy_clone_url"}}" aria-label="{{t .Lang "repo.copy_clone_url"}}">{{icon "copy"}}</button>
</div>
{{if not .Empty}}<a href="/repo-commits/{{.Repo.Name}}" class="gf-btn">{{icon "history"}} {{t .Lang "repo.commits_title"}}</a>{{end}}
+ {{if not .Empty}}<a href="/repo-mrs/{{.Repo.Name}}" class="gf-btn">{{icon "branch"}} {{t .Lang "mr.list_title"}}</a>{{end}}
{{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">{{t .Lang "nav.settings"}}</a>{{end}}
</div>
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go
index c5c2d96..49efd4f 100644
--- a/cmd/gitfed-web/render.go
+++ b/cmd/gitfed-web/render.go
@@ -143,6 +143,7 @@ const iconSprite = `<svg width="0" height="0" style="position:absolute" aria-hid
<symbol id="ic-bell" viewBox="0 0 24 24"><path d="M6 10.5c0-3.3 2.7-6 6-6s6 2.7 6 6v3.3l1.6 2.7H4.4L6 13.8V10.5Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9.8 18.5a2.3 2.3 0 0 0 4.4 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="ic-history" viewBox="0 0 24 24"><circle cx="12" cy="12.5" r="8" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 8v4.7l3.3 2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 3.3 5 6M16 3.3 19 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="ic-user" viewBox="0 0 24 24"><circle cx="12" cy="8.3" r="3.3" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M4.8 19c1-3.2 3.9-5 7.2-5s6.2 1.8 7.2 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
+<symbol id="ic-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
</defs>
</svg>`
@@ -179,6 +180,7 @@ const shellHeadSrc = `<!doctype html>
--ok-bg: rgba(95,191,143,0.14); --ok-fg: #7bd6a8;
--pending-bg: rgba(224,179,78,0.14); --pending-fg: #e0b34e;
--danger-bg: rgba(242,139,130,0.14); --danger-fg: #f28b82;
+ --violet-bg: rgba(167,139,250,0.14); --violet-fg: #b9a3f7;
--mono: "SF Mono", "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
@@ -413,6 +415,58 @@ const shellHeadSrc = `<!doctype html>
.gf-diff .line.hunk { color: var(--accent); background: var(--surface-2); }
.gf-diff .line.ctx { color: var(--text-dim); }
+ /* Merge requests */
+ .badge.open { background: var(--ok-bg); color: var(--ok-fg); }
+ .badge.merged { background: var(--violet-bg); color: var(--violet-fg); }
+ .badge.closed { background: var(--danger-bg); color: var(--danger-fg); }
+
+ .mr-row { display: flex; align-items: flex-start; gap: 0.8rem; padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit; }
+ .mr-row:last-child { border-bottom: none; }
+ .mr-row:hover { background: var(--surface-2); }
+ .mr-status-dot { width: 9px; height: 9px; border-radius: 50%; margin-top: 0.4rem; flex-shrink: 0; }
+ .mr-status-dot.open { background: var(--ok-fg); }
+ .mr-status-dot.merged { background: var(--violet-fg); }
+ .mr-status-dot.closed { background: var(--danger-fg); }
+ .mr-main { flex: 1; min-width: 0; }
+ .mr-main .title { font-size: 0.94rem; }
+ .mr-main .meta { font-size: 0.8rem; color: var(--text-faint); margin-top: 0.25rem; font-family: var(--mono); }
+ .mr-main .meta .branches { color: var(--text-dim); }
+
+ .branch-picker { display: flex; align-items: center; gap: 0.7rem; }
+ .branch-picker select { flex: 1; font-family: var(--mono); }
+ .branch-picker .arrow { color: var(--text-faint); display: inline-flex; }
+
+ .mr-detail-head { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--border); }
+ .mr-detail-head .titlebar { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
+ .mr-detail-head .titlebar h2 { font-size: 1.08rem; margin: 0; font-weight: 650; }
+ .mr-detail-head .num { color: var(--text-faint); font-weight: 400; }
+ .mr-detail-head .branches { font-family: var(--mono); font-size: 0.84rem; color: var(--text-dim); display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.7rem; }
+ .mr-detail-head .branches code { background: var(--surface-2); padding: 0.15rem 0.5rem; border-radius: 5px; }
+ .mr-detail-head .desc { color: var(--text-dim); font-size: 0.88rem; white-space: pre-wrap; margin: 0.6rem 0 0; }
+ .mr-detail-head .meta { font-size: 0.8rem; color: var(--text-faint); margin-top: 0.6rem; }
+
+ .merge-panel { padding: 1rem 1.2rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
+ .merge-panel.clean { background: var(--ok-bg); }
+ .merge-panel.conflict { background: var(--danger-bg); }
+ .merge-panel .msg { font-size: 0.88rem; }
+ .merge-panel.clean .msg { color: var(--ok-fg); }
+ .merge-panel.conflict .msg { color: var(--danger-fg); }
+ .merge-panel .msg strong { display: block; margin-bottom: 0.15rem; font-size: 0.9rem; }
+ .merge-panel .msg .files { font-family: var(--mono); font-size: 0.78rem; opacity: 0.85; }
+ .merge-panel .msg a { text-decoration: underline; }
+
+ .comment { display: flex; gap: 0.7rem; padding: 0.9rem 1.2rem; border-bottom: 1px solid var(--border); }
+ .comment:last-child { border-bottom: none; }
+ .comment .avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--surface-3); display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: 700; color: var(--text-dim); flex-shrink: 0; }
+ .comment .body { flex: 1; min-width: 0; }
+ .comment .head { font-size: 0.82rem; margin-bottom: 0.25rem; }
+ .comment .head .author { font-weight: 600; }
+ .comment .head .when { color: var(--text-faint); margin-left: 0.4rem; }
+ .comment .text { font-size: 0.88rem; color: var(--text-dim); white-space: pre-wrap; }
+ .comment-form { padding: 1rem 1.2rem; }
+ .comment-form textarea { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 0.6rem 0.7rem; color: var(--text); font-family: inherit; font-size: 0.88rem; min-height: 70px; resize: vertical; }
+ .comment-form .row { display: flex; justify-content: flex-end; margin-top: 0.6rem; }
+
.gf-new-repo summary { list-style: none; cursor: pointer; }
.gf-new-repo summary::-webkit-details-marker { display: none; }
.gf-new-repo[open] summary { margin-bottom: 0.5rem; }
@@ -500,6 +554,8 @@ const shellHeadSrc = `<!doctype html>
button[type="submit"]:not(.linklike):hover, .gf-btn.primary:hover { filter: brightness(1.08); }
button.danger { background: var(--danger-bg); border-color: transparent; color: var(--danger-fg); }
button.danger:hover { background: rgba(242,139,130,0.24); }
+ button[type="submit"].primary { background: var(--ok-bg); border-color: transparent; color: var(--ok-fg); }
+ button[type="submit"].primary:hover { background: rgba(123,214,168,0.24); }
/* ---------- misc components ---------- */
.msg { padding: 0.6rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go
index c29d9d5..1730b1e 100644
--- a/cmd/gitfed-web/routes.go
+++ b/cmd/gitfed-web/routes.go
@@ -9,6 +9,8 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /repo-blob/{repo...}", s.handleRepoBlob)
mux.HandleFunc("GET /repo-commits/{repo...}", s.handleRepoCommits)
mux.HandleFunc("GET /repo-commit/{repo...}", s.handleRepoCommitDetail)
+ mux.HandleFunc("GET /repo-mrs/{repo...}", s.handleMRList)
+ mux.HandleFunc("GET /repo-mr/{repo...}", s.handleMRDetail)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
@@ -37,6 +39,11 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /repo-grant/{repo...}", s.requireLogin(s.handleCollabGrant))
mux.HandleFunc("POST /repo-revoke/{repo...}", s.requireLogin(s.handleCollabRevoke))
mux.HandleFunc("POST /repo-delete/{repo...}", s.requireLogin(s.handleRepoDelete))
+ mux.HandleFunc("GET /repo-mr-new/{repo...}", s.requireLogin(s.handleMRNewForm))
+ mux.HandleFunc("POST /repo-mr-new/{repo...}", s.requireLogin(s.handleMRCreate))
+ mux.HandleFunc("POST /repo-mr-merge/{repo...}", s.requireLogin(s.handleMRMerge))
+ mux.HandleFunc("POST /repo-mr-close/{repo...}", s.requireLogin(s.handleMRClose))
+ mux.HandleFunc("POST /repo-mr-comment/{repo...}", s.requireLogin(s.handleMRComment))
mux.HandleFunc("GET /settings", s.requireLogin(s.handleSettings))
mux.HandleFunc("POST /settings/keys/add", s.requireLogin(s.handleAddOwnKey))
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go
index 3f2acb9..1b0b627 100644
--- a/internal/admin/admin.go
+++ b/internal/admin/admin.go
@@ -56,6 +56,17 @@ type Ops interface {
ListCommits(name string, limit int) (commits []gitexec.Commit, found bool, err error)
ShowCommit(name, hash string) (detail gitexec.CommitDetail, found bool, err error)
CommitDiff(name, hash string) (diff string, truncated bool, err error)
+ ListBranches(name string) (branches []string, err error)
+
+ CreateMergeRequest(repoName, title, description, author, sourceBranch, targetBranch string) (store.MergeRequest, error)
+ ListMergeRequests(repoName string) ([]store.MergeRequest, error)
+ GetMergeRequest(repoName string, number int) (store.MergeRequest, error)
+ MergeRequestDiff(repoName string, number int) (files []gitexec.DiffFile, diff string, truncated bool, found bool, err error)
+ CheckMergeRequestMergeable(repoName string, number int) (gitexec.MergeResult, error)
+ MergeMergeRequest(repoName string, number int, actor string) (store.MergeRequest, gitexec.MergeResult, error)
+ CloseMergeRequest(repoName string, number int) error
+ ListMRComments(repoName string, number int) ([]store.MRComment, error)
+ AddMRComment(repoName string, number int, author, body string) (store.MRComment, error)
GetACL(repoName string) (store.ACL, error)
GrantCollaborator(repoName, principal, actor string, role store.Role) error
@@ -362,6 +373,112 @@ func (a *Admin) CommitDiff(name, hash string) (string, bool, error) {
return gitexec.CommitDiff(repo.Path, hash)
}
+func (a *Admin) ListBranches(name string) ([]string, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return nil, err
+ }
+ return gitexec.ListBranches(repo.Path)
+}
+
+func (a *Admin) CreateMergeRequest(repoName, title, description, author, sourceBranch, targetBranch string) (store.MergeRequest, error) {
+ return a.Store.CreateMergeRequest(store.MergeRequest{
+ Repo: repoName, Title: title, Description: description, Author: author,
+ SourceBranch: sourceBranch, TargetBranch: targetBranch,
+ })
+}
+
+func (a *Admin) ListMergeRequests(repoName string) ([]store.MergeRequest, error) {
+ return a.Store.ListMergeRequests(repoName)
+}
+
+func (a *Admin) GetMergeRequest(repoName string, number int) (store.MergeRequest, error) {
+ return a.Store.GetMergeRequest(repoName, number)
+}
+
+// MergeRequestDiff computes mr's live diff against its two branches' current
+// tips — never a stored snapshot, so a new push to the source branch is
+// reflected the next time this is called with no extra bookkeeping.
+func (a *Admin) MergeRequestDiff(repoName string, number int) ([]gitexec.DiffFile, string, bool, bool, error) {
+ mr, err := a.Store.GetMergeRequest(repoName, number)
+ if err != nil {
+ return nil, "", false, false, err
+ }
+ repo, err := a.Store.GetRepo(repoName)
+ if err != nil {
+ return nil, "", false, false, err
+ }
+ return gitexec.BranchDiff(repo.Path, mr.TargetBranch, mr.SourceBranch)
+}
+
+func (a *Admin) CheckMergeRequestMergeable(repoName string, number int) (gitexec.MergeResult, error) {
+ mr, err := a.Store.GetMergeRequest(repoName, number)
+ if err != nil {
+ return gitexec.MergeResult{}, err
+ }
+ repo, err := a.Store.GetRepo(repoName)
+ if err != nil {
+ return gitexec.MergeResult{}, err
+ }
+ return gitexec.CheckMergeable(repo.Path, mr.TargetBranch, mr.SourceBranch)
+}
+
+// MergeMergeRequest merges mr's source into its target with a real merge
+// commit attributed to actor, and records the outcome on the stored
+// MergeRequest. actor must already have been checked for write access by
+// the caller — this only re-checks that the MR is still open. A conflicted
+// merge is reported back (result.Clean == false) without changing the MR's
+// status, so the requester can resolve locally and try again.
+func (a *Admin) MergeMergeRequest(repoName string, number int, actor string) (store.MergeRequest, gitexec.MergeResult, error) {
+ mr, err := a.Store.GetMergeRequest(repoName, number)
+ if err != nil {
+ return store.MergeRequest{}, gitexec.MergeResult{}, err
+ }
+ if mr.Status != store.MROpen {
+ return mr, gitexec.MergeResult{}, fmt.Errorf("admin: merge request %s#%d is not open", repoName, number)
+ }
+ repo, err := a.Store.GetRepo(repoName)
+ if err != nil {
+ return mr, gitexec.MergeResult{}, err
+ }
+
+ authorName := actor
+ if username, _, ok := splitPrincipal(actor); ok {
+ authorName = username
+ }
+ msg := fmt.Sprintf("Merge branch '%s' into %s\n\n%s\n\n#%d", mr.SourceBranch, mr.TargetBranch, mr.Title, mr.Number)
+
+ commit, result, err := gitexec.MergeBranches(repo.Path, mr.TargetBranch, mr.SourceBranch, msg, authorName, actor)
+ if err != nil || !result.Clean {
+ return mr, result, err
+ }
+
+ mr.Status = store.MRMerged
+ mr.MergedBy = actor
+ mr.MergeCommit = commit
+ if err := a.Store.UpdateMergeRequest(mr); err != nil {
+ return mr, result, err
+ }
+ return mr, result, nil
+}
+
+func (a *Admin) CloseMergeRequest(repoName string, number int) error {
+ mr, err := a.Store.GetMergeRequest(repoName, number)
+ if err != nil {
+ return err
+ }
+ mr.Status = store.MRClosed
+ return a.Store.UpdateMergeRequest(mr)
+}
+
+func (a *Admin) ListMRComments(repoName string, number int) ([]store.MRComment, error) {
+ return a.Store.ListMRComments(repoName, number)
+}
+
+func (a *Admin) AddMRComment(repoName string, number int, author, body string) (store.MRComment, error) {
+ return a.Store.AddMRComment(store.MRComment{Repo: repoName, Number: number, Author: author, Body: body})
+}
+
// GrantCollaborator adds/updates a collaborator's role on a repo. If the
// principal belongs to a remote domain, it first resolves trust for that
// domain (§5.2/§6); for the whitelist policy this leaves the domain pending
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go
index 6d0d5e8..b44246c 100644
--- a/internal/adminrpc/client.go
+++ b/internal/adminrpc/client.go
@@ -250,6 +250,67 @@ func (c *Client) CommitDiff(name, hash string) (string, bool, error) {
return out.Diff, out.Truncated, err
}
+func (c *Client) ListBranches(name string) ([]string, error) {
+ var out listBranchesResult
+ err := c.call(methodListBranches, nameArgs{Name: name}, &out)
+ return out.Branches, err
+}
+
+func (c *Client) CreateMergeRequest(repoName, title, description, author, sourceBranch, targetBranch string) (store.MergeRequest, error) {
+ var out mrResult
+ err := c.call(methodCreateMergeRequest, createMRArgs{
+ Repo: repoName, Title: title, Description: description, Author: author,
+ SourceBranch: sourceBranch, TargetBranch: targetBranch,
+ }, &out)
+ return out.MR, err
+}
+
+func (c *Client) ListMergeRequests(repoName string) ([]store.MergeRequest, error) {
+ var out listMRResult
+ err := c.call(methodListMergeRequests, nameArgs{Name: repoName}, &out)
+ return out.MRs, err
+}
+
+func (c *Client) GetMergeRequest(repoName string, number int) (store.MergeRequest, error) {
+ var out mrResult
+ err := c.call(methodGetMergeRequest, mrArgs{Repo: repoName, Number: number}, &out)
+ return out.MR, err
+}
+
+func (c *Client) MergeRequestDiff(repoName string, number int) ([]gitexec.DiffFile, string, bool, bool, error) {
+ var out mrDiffResult
+ err := c.call(methodMergeRequestDiff, mrArgs{Repo: repoName, Number: number}, &out)
+ return out.Files, out.Diff, out.Truncated, out.Found, err
+}
+
+func (c *Client) CheckMergeRequestMergeable(repoName string, number int) (gitexec.MergeResult, error) {
+ var out mergeResultRPC
+ err := c.call(methodCheckMergeRequestMergeable, mrArgs{Repo: repoName, Number: number}, &out)
+ return out.Result, err
+}
+
+func (c *Client) MergeMergeRequest(repoName string, number int, actor string) (store.MergeRequest, gitexec.MergeResult, error) {
+ var out mergeMRResult
+ err := c.call(methodMergeMergeRequest, mergeMRArgs{Repo: repoName, Number: number, Actor: actor}, &out)
+ return out.MR, out.Result, err
+}
+
+func (c *Client) CloseMergeRequest(repoName string, number int) error {
+ return c.call(methodCloseMergeRequest, mrArgs{Repo: repoName, Number: number}, nil)
+}
+
+func (c *Client) ListMRComments(repoName string, number int) ([]store.MRComment, error) {
+ var out listMRCommentsResult
+ err := c.call(methodListMRComments, mrArgs{Repo: repoName, Number: number}, &out)
+ return out.Comments, err
+}
+
+func (c *Client) AddMRComment(repoName string, number int, author, body string) (store.MRComment, error) {
+ var out addMRCommentResult
+ err := c.call(methodAddMRComment, mrCommentArgs{Repo: repoName, Number: number, Author: author, Body: body}, &out)
+ return out.Comment, err
+}
+
func (c *Client) PinRepo(principal, domain, repo, label string) error {
return c.call(methodPinRepo, pinArgs{Principal: principal, Domain: domain, Repo: repo, Label: label}, nil)
}
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go
index bf1fdbb..9dcae0c 100644
--- a/internal/adminrpc/protocol.go
+++ b/internal/adminrpc/protocol.go
@@ -48,6 +48,17 @@ const (
methodListCommits = "ListCommits"
methodShowCommit = "ShowCommit"
methodCommitDiff = "CommitDiff"
+ methodListBranches = "ListBranches"
+
+ methodCreateMergeRequest = "CreateMergeRequest"
+ methodListMergeRequests = "ListMergeRequests"
+ methodGetMergeRequest = "GetMergeRequest"
+ methodMergeRequestDiff = "MergeRequestDiff"
+ methodCheckMergeRequestMergeable = "CheckMergeRequestMergeable"
+ methodMergeMergeRequest = "MergeMergeRequest"
+ methodCloseMergeRequest = "CloseMergeRequest"
+ methodListMRComments = "ListMRComments"
+ methodAddMRComment = "AddMRComment"
methodPinRepo = "PinRepo"
methodUnpinRepo = "UnpinRepo"
@@ -260,3 +271,66 @@ type commitDiffResult struct {
Diff string `json:"diff"`
Truncated bool `json:"truncated"`
}
+
+type listBranchesResult struct {
+ Branches []string `json:"branches"`
+}
+
+type createMRArgs struct {
+ Repo string `json:"repo"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Author string `json:"author"`
+ SourceBranch string `json:"source_branch"`
+ TargetBranch string `json:"target_branch"`
+}
+
+type mrArgs struct {
+ Repo string `json:"repo"`
+ Number int `json:"number"`
+}
+
+type mergeMRArgs struct {
+ Repo string `json:"repo"`
+ Number int `json:"number"`
+ Actor string `json:"actor"`
+}
+
+type mrResult struct {
+ MR store.MergeRequest `json:"mr"`
+}
+
+type listMRResult struct {
+ MRs []store.MergeRequest `json:"mrs"`
+}
+
+type mrDiffResult struct {
+ Files []gitexec.DiffFile `json:"files"`
+ Diff string `json:"diff"`
+ Truncated bool `json:"truncated"`
+ Found bool `json:"found"`
+}
+
+type mergeResultRPC struct {
+ Result gitexec.MergeResult `json:"result"`
+}
+
+type mergeMRResult struct {
+ MR store.MergeRequest `json:"mr"`
+ Result gitexec.MergeResult `json:"result"`
+}
+
+type mrCommentArgs struct {
+ Repo string `json:"repo"`
+ Number int `json:"number"`
+ Author string `json:"author"`
+ Body string `json:"body"`
+}
+
+type listMRCommentsResult struct {
+ Comments []store.MRComment `json:"comments"`
+}
+
+type addMRCommentResult struct {
+ Comment store.MRComment `json:"comment"`
+}
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go
index f33035f..1c265d1 100644
--- a/internal/adminrpc/server.go
+++ b/internal/adminrpc/server.go
@@ -310,6 +310,85 @@ func (s *Server) dispatch(req wireRequest) (any, error) {
diff, truncated, err := s.ops.CommitDiff(a.Name, a.Hash)
return commitDiffResult{Diff: diff, Truncated: truncated}, err
+ case methodListBranches:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ branches, err := s.ops.ListBranches(a.Name)
+ return listBranchesResult{Branches: branches}, err
+
+ case methodCreateMergeRequest:
+ var a createMRArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ mr, err := s.ops.CreateMergeRequest(a.Repo, a.Title, a.Description, a.Author, a.SourceBranch, a.TargetBranch)
+ return mrResult{MR: mr}, err
+
+ case methodListMergeRequests:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ mrs, err := s.ops.ListMergeRequests(a.Name)
+ return listMRResult{MRs: mrs}, err
+
+ case methodGetMergeRequest:
+ var a mrArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ mr, err := s.ops.GetMergeRequest(a.Repo, a.Number)
+ return mrResult{MR: mr}, err
+
+ case methodMergeRequestDiff:
+ var a mrArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ files, diff, truncated, found, err := s.ops.MergeRequestDiff(a.Repo, a.Number)
+ return mrDiffResult{Files: files, Diff: diff, Truncated: truncated, Found: found}, err
+
+ case methodCheckMergeRequestMergeable:
+ var a mrArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ result, err := s.ops.CheckMergeRequestMergeable(a.Repo, a.Number)
+ return mergeResultRPC{Result: result}, err
+
+ case methodMergeMergeRequest:
+ var a mergeMRArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ mr, result, err := s.ops.MergeMergeRequest(a.Repo, a.Number, a.Actor)
+ return mergeMRResult{MR: mr, Result: result}, err
+
+ case methodCloseMergeRequest:
+ var a mrArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.CloseMergeRequest(a.Repo, a.Number)
+
+ case methodListMRComments:
+ var a mrArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ comments, err := s.ops.ListMRComments(a.Repo, a.Number)
+ return listMRCommentsResult{Comments: comments}, err
+
+ case methodAddMRComment:
+ var a mrCommentArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ comment, err := s.ops.AddMRComment(a.Repo, a.Number, a.Author, a.Body)
+ return addMRCommentResult{Comment: comment}, err
+
case methodPinRepo:
var a pinArgs
if err := json.Unmarshal(req.Args, &a); err != nil {
internal/gitexec/gitexec.go
diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go
index f1d0922..5440ec0 100644
--- a/internal/gitexec/gitexec.go
+++ b/internal/gitexec/gitexec.go
@@ -420,7 +420,27 @@ func commitFiles(repoPath, hash string) ([]DiffFile, error) {
if err != nil {
return nil, err
}
+ return mergeStatusAndNumstat(statusLines, numLines), nil
+}
+
+// branchFiles lists the files that merging source into target would touch
+// — the same name-status/numstat combination as commitFiles, but diffing
+// against the merge base of the two branches (git's "..." range syntax)
+// rather than a single commit's parent.
+func branchFiles(repoPath, target, source string) ([]DiffFile, error) {
+ rangeSpec := target + "..." + source
+ statusLines, err := runGitLines(repoPath, "diff", "--name-status", rangeSpec)
+ if err != nil {
+ return nil, err
+ }
+ numLines, err := runGitLines(repoPath, "diff", "--numstat", rangeSpec)
+ if err != nil {
+ return nil, err
+ }
+ return mergeStatusAndNumstat(statusLines, numLines), nil
+}
+func mergeStatusAndNumstat(statusLines, numLines []string) []DiffFile {
files := make([]DiffFile, 0, len(statusLines))
for i, line := range statusLines {
f := strings.Split(line, "\t")
@@ -447,7 +467,7 @@ func commitFiles(repoPath, hash string) ([]DiffFile, error) {
}
files = append(files, DiffFile{Path: path, Status: statusName(code[0]), Insertions: add, Deletions: del, Binary: binary})
}
- return files, nil
+ return files
}
func runGitLines(repoPath string, args ...string) ([]string, error) {
@@ -478,10 +498,193 @@ func CommitDiff(repoPath, hash string) (diff string, truncated bool, err error)
if err := cmd.Run(); err != nil {
return "", false, fmt.Errorf("gitexec: diff %s: %w: %s", hash, err, stderr.String())
}
- lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n")
- truncated = len(lines) > commitDiffMaxLines
- if truncated {
+ return truncateDiff(stdout.String()), len(strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n")) > commitDiffMaxLines, nil
+}
+
+func truncateDiff(patch string) string {
+ lines := strings.Split(strings.TrimRight(patch, "\n"), "\n")
+ if len(lines) > commitDiffMaxLines {
lines = lines[:commitDiffMaxLines]
}
- return strings.Join(lines, "\n"), truncated, nil
+ return strings.Join(lines, "\n")
+}
+
+// ListBranches returns the repo's branch names, alphabetically.
+func ListBranches(repoPath string) ([]string, error) {
+ lines, err := runGitLines(repoPath, "for-each-ref", "--format=%(refname:short)", "--sort=refname", "refs/heads/")
+ if err != nil {
+ return nil, err
+ }
+ return lines, nil
+}
+
+// BranchDiff returns the changed-file summary and unified diff patch for
+// what merging source into target would introduce — diffed against their
+// merge base (git's "..." range), the same comparison a merge itself
+// would make, not a flat two-tree diff. found is false (nil error) if
+// either branch doesn't exist.
+func BranchDiff(repoPath, target, source string) (files []DiffFile, diff string, truncated bool, found bool, err error) {
+ for _, ref := range []string{target, source} {
+ if exec.Command("git", "--git-dir="+repoPath, "rev-parse", "--verify", "-q", "refs/heads/"+ref).Run() != nil {
+ return nil, "", false, false, nil
+ }
+ }
+
+ files, err = branchFiles(repoPath, target, source)
+ if err != nil {
+ return nil, "", false, false, err
+ }
+
+ cmd := exec.Command("git", "--git-dir="+repoPath, "diff", "--no-color", target+"..."+source)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return nil, "", false, false, fmt.Errorf("gitexec: diff %s...%s: %w: %s", target, source, err, stderr.String())
+ }
+ full := stdout.String()
+ return files, truncateDiff(full), len(strings.Split(strings.TrimRight(full, "\n"), "\n")) > commitDiffMaxLines, true, nil
+}
+
+// MergeResult reports the outcome of a mergeability check or an attempted
+// merge: either Clean, or blocked with the specific files that conflict.
+type MergeResult struct {
+ Clean bool
+ ConflictFiles []string
+}
+
+// withScratchWorktree runs fn against a throwaway linked worktree checked
+// out at base, then always removes it — used so mergeability checks and
+// real merges never touch the repo's actual branch refs or working state
+// except through the explicit update-ref in MergeBranches.
+func withScratchWorktree(repoPath, base string, fn func(dir string) error) error {
+ dir, err := os.MkdirTemp("", "gitfed-merge-")
+ if err != nil {
+ return fmt.Errorf("gitexec: scratch worktree: %w", err)
+ }
+ if err := os.Remove(dir); err != nil {
+ return fmt.Errorf("gitexec: scratch worktree: %w", err)
+ }
+ defer func() {
+ _ = exec.Command("git", "--git-dir="+repoPath, "worktree", "remove", "--force", dir).Run()
+ _ = os.RemoveAll(dir)
+ }()
+
+ addCmd := exec.Command("git", "--git-dir="+repoPath, "worktree", "add", "--detach", "--quiet", dir, base)
+ var stderr bytes.Buffer
+ addCmd.Stderr = &stderr
+ if err := addCmd.Run(); err != nil {
+ return fmt.Errorf("gitexec: worktree add %s: %w: %s", base, err, stderr.String())
+ }
+ return fn(dir)
+}
+
+func conflictedFiles(dir string) []string {
+ out, err := exec.Command("git", "-C", dir, "diff", "--name-only", "--diff-filter=U").Output()
+ if err != nil {
+ return nil
+ }
+ var files []string
+ for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ if l != "" {
+ files = append(files, l)
+ }
+ }
+ return files
+}
+
+// CheckMergeable reports whether source merges cleanly into target,
+// without touching any real ref — the attempt happens in a scratch
+// worktree that's discarded either way.
+func CheckMergeable(repoPath, target, source string) (MergeResult, error) {
+ var result MergeResult
+ err := withScratchWorktree(repoPath, target, func(dir string) error {
+ cmd := exec.Command("git", "-C", dir, "-c", "user.name=gitfed", "-c", "user.email=gitfed@localhost",
+ "merge", "--no-commit", "--no-ff", "--quiet", source)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if runErr := cmd.Run(); runErr != nil {
+ if conflicts := conflictedFiles(dir); len(conflicts) > 0 {
+ result.ConflictFiles = conflicts
+ return nil
+ }
+ return fmt.Errorf("gitexec: merge check %s into %s: %w: %s", source, target, runErr, stderr.String())
+ }
+ result.Clean = true
+ return nil
+ })
+ return result, err
+}
+
+// MergeBranches merges source into target with a real merge commit
+// (always --no-ff, so the merge is traceable even when a fast-forward
+// would have sufficed) and atomically updates refs/heads/target via
+// compare-and-swap: if target moved (e.g. a concurrent push) between the
+// merge being computed and the ref update, the update is rejected instead
+// of silently discarding that commit. The merge itself happens in a
+// scratch worktree so target's checked-out state, if any, is never
+// touched directly.
+func MergeBranches(repoPath, target, source, message, authorName, authorEmail string) (mergeCommit string, result MergeResult, err error) {
+ oldTip, err := revParse(repoPath, "refs/heads/"+target)
+ if err != nil {
+ return "", MergeResult{}, err
+ }
+
+ err = withScratchWorktree(repoPath, target, func(dir string) error {
+ cmd := exec.Command("git", "-C", dir,
+ "-c", "user.name="+authorName, "-c", "user.email="+authorEmail,
+ "merge", "--no-ff", "--quiet", "-m", message, source)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if runErr := cmd.Run(); runErr != nil {
+ if conflicts := conflictedFiles(dir); len(conflicts) > 0 {
+ result.ConflictFiles = conflicts
+ return nil
+ }
+ return fmt.Errorf("gitexec: merge %s into %s: %w: %s", source, target, runErr, stderr.String())
+ }
+ result.Clean = true
+ tip, revErr := headOf(dir)
+ if revErr != nil {
+ return revErr
+ }
+ mergeCommit = tip
+ return nil
+ })
+ if err != nil || !result.Clean {
+ return "", result, err
+ }
+
+ updateCmd := exec.Command("git", "--git-dir="+repoPath, "update-ref", "refs/heads/"+target, mergeCommit, oldTip)
+ var stderr bytes.Buffer
+ updateCmd.Stderr = &stderr
+ if err := updateCmd.Run(); err != nil {
+ return "", result, fmt.Errorf("gitexec: update-ref %s (concurrent push?): %w: %s", target, err, stderr.String())
+ }
+ return mergeCommit, result, nil
+}
+
+func revParse(repoPath, ref string) (string, error) {
+ cmd := exec.Command("git", "--git-dir="+repoPath, "rev-parse", ref)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("gitexec: rev-parse %s: %w: %s", ref, err, stderr.String())
+ }
+ return strings.TrimSpace(stdout.String()), nil
+}
+
+// headOf resolves HEAD inside a normal (non-bare) working directory, such
+// as a scratch worktree — unlike revParse, this uses -C instead of
+// --git-dir since dir is a worktree, not the bare repo itself.
+func headOf(dir string) (string, error) {
+ cmd := exec.Command("git", "-C", dir, "rev-parse", "HEAD")
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("gitexec: rev-parse HEAD in %s: %w: %s", dir, err, stderr.String())
+ }
+ return strings.TrimSpace(stdout.String()), nil
}
internal/gitexec/gitexec_test.go
diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go
index 054d7cf..8bd095e 100644
--- a/internal/gitexec/gitexec_test.go
+++ b/internal/gitexec/gitexec_test.go
@@ -4,6 +4,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strings"
"testing"
)
@@ -224,3 +225,206 @@ func TestDefaultBranchNameEmptyRepo(t *testing.T) {
t.Fatal("empty repo should not report a default branch")
}
}
+
+// setupMergeTestRepo builds a bare repo with three branches: main, a
+// feature branch that merges into it cleanly, and a feature branch that
+// conflicts with a change also made on main after the branch point.
+func setupMergeTestRepo(t *testing.T) (barePath string) {
+ t.Helper()
+ tmp := t.TempDir()
+ barePath = filepath.Join(tmp, "repo.git")
+ if err := InitBareRepo(barePath); err != nil {
+ t.Fatalf("init bare repo: %v", err)
+ }
+
+ work := filepath.Join(tmp, "work")
+ if err := os.Mkdir(work, 0755); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "init", "-q", "-b", "main")
+ run(t, work, "git", "remote", "add", "origin", barePath)
+
+ writeAndCommit := func(name, content, msg string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(work, name), []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "add", name)
+ run(t, work, "git", "commit", "-q", "-m", msg)
+ }
+
+ writeAndCommit("shared.txt", "line one\n", "initial")
+ run(t, work, "git", "push", "-q", "origin", "main")
+
+ run(t, work, "git", "checkout", "-q", "-b", "feature-clean")
+ writeAndCommit("clean.txt", "new file, no overlap\n", "feature-clean change")
+ run(t, work, "git", "push", "-q", "origin", "feature-clean")
+
+ run(t, work, "git", "checkout", "-q", "main")
+ run(t, work, "git", "checkout", "-q", "-b", "feature-conflict")
+ writeAndCommit("shared.txt", "feature-conflict's version\n", "feature-conflict change")
+ run(t, work, "git", "push", "-q", "origin", "feature-conflict")
+
+ run(t, work, "git", "checkout", "-q", "main")
+ writeAndCommit("shared.txt", "main's own diverging version\n", "main diverges")
+ run(t, work, "git", "push", "-q", "origin", "main")
+
+ return barePath
+}
+
+func TestListBranches(t *testing.T) {
+ bare := setupMergeTestRepo(t)
+ branches, err := ListBranches(bare)
+ if err != nil {
+ t.Fatalf("ListBranches: %v", err)
+ }
+ want := []string{"feature-clean", "feature-conflict", "main"}
+ if len(branches) != len(want) {
+ t.Fatalf("branches = %v, want %v", branches, want)
+ }
+ for i, b := range branches {
+ if b != want[i] {
+ t.Fatalf("branches = %v, want %v", branches, want)
+ }
+ }
+}
+
+func TestBranchDiff(t *testing.T) {
+ bare := setupMergeTestRepo(t)
+
+ files, diff, truncated, found, err := BranchDiff(bare, "main", "feature-clean")
+ if err != nil {
+ t.Fatalf("BranchDiff: %v", err)
+ }
+ if !found {
+ t.Fatal("expected found=true for two real branches")
+ }
+ if truncated {
+ t.Fatal("small diff should not be truncated")
+ }
+ if len(files) != 1 || files[0].Path != "clean.txt" || files[0].Status != "added" {
+ t.Fatalf("files = %+v, want one added clean.txt", files)
+ }
+ if !strings.Contains(diff, "clean.txt") {
+ t.Fatalf("diff missing clean.txt: %q", diff)
+ }
+
+ _, _, _, found, err = BranchDiff(bare, "main", "does-not-exist")
+ if err != nil {
+ t.Fatalf("BranchDiff nonexistent branch: %v", err)
+ }
+ if found {
+ t.Fatal("nonexistent source branch should report found=false")
+ }
+}
+
+func TestCheckMergeable(t *testing.T) {
+ bare := setupMergeTestRepo(t)
+
+ result, err := CheckMergeable(bare, "main", "feature-clean")
+ if err != nil {
+ t.Fatalf("CheckMergeable clean: %v", err)
+ }
+ if !result.Clean {
+ t.Fatalf("feature-clean should merge cleanly, got conflicts: %v", result.ConflictFiles)
+ }
+
+ result, err = CheckMergeable(bare, "main", "feature-conflict")
+ if err != nil {
+ t.Fatalf("CheckMergeable conflict: %v", err)
+ }
+ if result.Clean {
+ t.Fatal("feature-conflict should not merge cleanly")
+ }
+ if len(result.ConflictFiles) != 1 || result.ConflictFiles[0] != "shared.txt" {
+ t.Fatalf("ConflictFiles = %v, want [shared.txt]", result.ConflictFiles)
+ }
+
+ // main's own ref must be untouched by a mergeability check.
+ tip, err := revParse(bare, "refs/heads/main")
+ if err != nil {
+ t.Fatal(err)
+ }
+ branches, err := ListBranches(bare)
+ if err != nil || len(branches) != 3 {
+ t.Fatalf("branches after CheckMergeable = %v, err %v — checking mergeability must not create/delete branches", branches, err)
+ }
+ if tip == "" {
+ t.Fatal("main should still resolve")
+ }
+}
+
+func TestMergeBranchesClean(t *testing.T) {
+ bare := setupMergeTestRepo(t)
+
+ oldTip, err := revParse(bare, "refs/heads/main")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ commit, result, err := MergeBranches(bare, "main", "feature-clean", "Merge feature-clean", "Test User", "test@example.com")
+ if err != nil {
+ t.Fatalf("MergeBranches: %v", err)
+ }
+ if !result.Clean {
+ t.Fatalf("expected clean merge, got conflicts: %v", result.ConflictFiles)
+ }
+ if commit == "" {
+ t.Fatal("expected a merge commit hash")
+ }
+
+ newTip, err := revParse(bare, "refs/heads/main")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if newTip != commit {
+ t.Fatalf("refs/heads/main = %s, want it to point at the merge commit %s", newTip, commit)
+ }
+ if newTip == oldTip {
+ t.Fatal("main did not move")
+ }
+
+ content, found, err := ReadFileAtHEAD(bare, "clean.txt")
+ if err != nil || !found {
+ t.Fatalf("clean.txt should exist on main after merge: found=%v err=%v", found, err)
+ }
+ if content != "new file, no overlap\n" {
+ t.Fatalf("clean.txt content = %q", content)
+ }
+}
+
+func TestMergeBranchesConflict(t *testing.T) {
+ bare := setupMergeTestRepo(t)
+
+ oldTip, err := revParse(bare, "refs/heads/main")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ commit, result, err := MergeBranches(bare, "main", "feature-conflict", "Merge feature-conflict", "Test User", "test@example.com")
+ if err != nil {
+ t.Fatalf("MergeBranches: %v", err)
+ }
+ if result.Clean {
+ t.Fatal("expected a conflicted merge")
+ }
+ if commit != "" {
+ t.Fatalf("commit = %q, want empty on conflict", commit)
+ }
+ if len(result.ConflictFiles) != 1 || result.ConflictFiles[0] != "shared.txt" {
+ t.Fatalf("ConflictFiles = %v, want [shared.txt]", result.ConflictFiles)
+ }
+
+ newTip, err := revParse(bare, "refs/heads/main")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if newTip != oldTip {
+ t.Fatal("main must not move when the merge conflicts")
+ }
+
+ branches, err := ListBranches(bare)
+ if err != nil || len(branches) != 3 {
+ t.Fatalf("branches after a conflicted merge attempt = %v, err %v — no stray branch/worktree should be left behind", branches, err)
+ }
+}
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go
index 8f587ce..968506e 100644
--- a/internal/i18n/strings_en.go
+++ b/internal/i18n/strings_en.go
@@ -286,4 +286,37 @@ var en = map[string]string{
"repo.msg_granted": "granted %s %s",
"repo.msg_revoked": "revoked %s",
"repo.msg_deleted": "deleted %s",
+
+ // ---------- merge requests ----------
+ "mr.list_title": "Merge requests",
+ "mr.new": "New merge request",
+ "mr.by": "opened by",
+ "mr.opened_by": "Opened by",
+ "mr.empty_title": "No merge requests yet",
+ "mr.empty_hint": "Open one to propose merging one branch into another.",
+ "mr.need_two_branches": "This repo needs at least two branches to open a merge request.",
+ "mr.branches_label": "Branches",
+ "mr.title_label": "Title",
+ "mr.description_label": "Description (optional)",
+ "mr.cancel": "Cancel",
+ "mr.open": "Open merge request",
+ "mr.status_open": "open",
+ "mr.status_merged": "merged",
+ "mr.status_closed": "closed",
+ "mr.branch_missing": "One of this merge request's branches no longer exists.",
+ "mr.clean_title": "No conflicts — ready to merge",
+ "mr.conflict_title": "Conflict — can't merge yet",
+ "mr.merge": "Merge",
+ "mr.close": "Close",
+ "mr.merged_note": "Merged by",
+ "mr.closed_note": "Closed without merging.",
+ "mr.comment_placeholder": "Add a comment…",
+ "mr.comment_submit": "Comment",
+ "mr.msg_invalid": "pick a title and two different branches",
+ "mr.msg_created": "opened merge request",
+ "mr.msg_merged": "merged",
+ "mr.msg_conflict": "can't merge: conflicting files — resolve locally and push, then try again",
+ "mr.msg_closed": "closed",
+ "mr.msg_comment_empty": "comment can't be empty",
+ "mr.msg_commented": "comment added",
}
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go
index 24fea5f..0643ac0 100644
--- a/internal/i18n/strings_fr.go
+++ b/internal/i18n/strings_fr.go
@@ -286,4 +286,37 @@ var fr = map[string]string{
"repo.msg_granted": "%s ajouté avec le rôle %s",
"repo.msg_revoked": "%s révoqué",
"repo.msg_deleted": "%s supprimé",
+
+ // ---------- merge requests ----------
+ "mr.list_title": "Merge requests",
+ "mr.new": "Nouvelle merge request",
+ "mr.by": "ouverte par",
+ "mr.opened_by": "Ouverte par",
+ "mr.empty_title": "Aucune merge request pour le moment",
+ "mr.empty_hint": "Ouvrez-en une pour proposer de fusionner une branche dans une autre.",
+ "mr.need_two_branches": "Ce dépôt a besoin d'au moins deux branches pour ouvrir une merge request.",
+ "mr.branches_label": "Branches",
+ "mr.title_label": "Titre",
+ "mr.description_label": "Description (optionnel)",
+ "mr.cancel": "Annuler",
+ "mr.open": "Ouvrir la merge request",
+ "mr.status_open": "ouverte",
+ "mr.status_merged": "fusionnée",
+ "mr.status_closed": "fermée",
+ "mr.branch_missing": "Une des branches de cette merge request n'existe plus.",
+ "mr.clean_title": "Aucun conflit — prête à fusionner",
+ "mr.conflict_title": "Conflit — fusion impossible pour le moment",
+ "mr.merge": "Fusionner",
+ "mr.close": "Fermer",
+ "mr.merged_note": "Fusionnée par",
+ "mr.closed_note": "Fermée sans fusion.",
+ "mr.comment_placeholder": "Ajouter un commentaire…",
+ "mr.comment_submit": "Commenter",
+ "mr.msg_invalid": "choisissez un titre et deux branches différentes",
+ "mr.msg_created": "merge request ouverte",
+ "mr.msg_merged": "fusionnée",
+ "mr.msg_conflict": "fusion impossible : fichiers en conflit — résolvez en local puis repoussez, et réessayez",
+ "mr.msg_closed": "fermée",
+ "mr.msg_comment_empty": "le commentaire ne peut pas être vide",
+ "mr.msg_commented": "commentaire ajouté",
}
internal/store/merge_requests.go
diff --git a/internal/store/merge_requests.go b/internal/store/merge_requests.go
new file mode 100644
index 0000000..ac17019
--- /dev/null
+++ b/internal/store/merge_requests.go
@@ -0,0 +1,144 @@
+package store
+
+import (
+ "fmt"
+ "sort"
+ "time"
+
+ bolt "go.etcd.io/bbolt"
+)
+
+type MRStatus string
+
+const (
+ MROpen MRStatus = "open"
+ MRMerged MRStatus = "merged"
+ MRClosed MRStatus = "closed"
+)
+
+// MergeRequest proposes merging SourceBranch into TargetBranch within a
+// single repo — there is no fork model in gitfed (collaborators share
+// write access to one repo, per DESIGN.md), so unlike GitHub this never
+// crosses a repo boundary. The diff itself is never stored: it's always
+// computed live from the two branches' current tips, so a new push to
+// SourceBranch is reflected without any extra bookkeeping here.
+type MergeRequest struct {
+ Repo string `json:"repo"`
+ Number int `json:"number"` // sequential per repo, like "#3"
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Author string `json:"author"` // principal who opened it
+ SourceBranch string `json:"source_branch"`
+ TargetBranch string `json:"target_branch"`
+ Status MRStatus `json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ MergedBy string `json:"merged_by,omitempty"`
+ MergeCommit string `json:"merge_commit,omitempty"`
+}
+
+// MRComment is one message in a merge request's discussion thread.
+// Comments are general, not attached to a specific diff line — see the
+// package doc for why inline per-line comments aren't in scope yet.
+type MRComment struct {
+ ID string `json:"id"`
+ Repo string `json:"repo"`
+ Number int `json:"number"`
+ Author string `json:"author"`
+ Body string `json:"body"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// Zero-padded so key order matches numeric order — purely cosmetic, since
+// every caller re-sorts in Go anyway.
+func mrKey(repo string, number int) string {
+ return fmt.Sprintf("%s\x00%08d", repo, number)
+}
+
+func mrCommentPrefix(repo string, number int) string {
+ return mrKey(repo, number) + "\x00"
+}
+
+// CreateMergeRequest assigns mr the next sequential number for its repo
+// and stores it as MROpen. Numbering and the write happen in the same
+// bbolt transaction, so concurrent creates for the same repo can't race to
+// the same number — bbolt allows only one Update transaction at a time.
+func (s *Store) CreateMergeRequest(mr MergeRequest) (MergeRequest, error) {
+ err := s.db.Update(func(tx *bolt.Tx) error {
+ existing, err := listJSONPrefix[MergeRequest](tx, bucketMergeRequests, mr.Repo+"\x00")
+ if err != nil {
+ return err
+ }
+ next := 1
+ for _, e := range existing {
+ if e.Number >= next {
+ next = e.Number + 1
+ }
+ }
+ mr.Number = next
+ mr.Status = MROpen
+ mr.CreatedAt = time.Now().UTC()
+ mr.UpdatedAt = mr.CreatedAt
+ return putJSON(tx, bucketMergeRequests, mrKey(mr.Repo, mr.Number), mr)
+ })
+ return mr, err
+}
+
+// ListMergeRequests returns repo's merge requests, newest (highest number)
+// first.
+func (s *Store) ListMergeRequests(repo string) ([]MergeRequest, error) {
+ var out []MergeRequest
+ err := s.db.View(func(tx *bolt.Tx) error {
+ var err error
+ out, err = listJSONPrefix[MergeRequest](tx, bucketMergeRequests, repo+"\x00")
+ return err
+ })
+ sort.Slice(out, func(i, j int) bool { return out[i].Number > out[j].Number })
+ return out, err
+}
+
+func (s *Store) GetMergeRequest(repo string, number int) (MergeRequest, error) {
+ var mr MergeRequest
+ err := s.db.View(func(tx *bolt.Tx) error {
+ return getJSON(tx, bucketMergeRequests, mrKey(repo, number), &mr)
+ })
+ return mr, err
+}
+
+// UpdateMergeRequest overwrites the stored record for mr's (Repo, Number)
+// — used for status transitions (merged/closed) and edits. UpdatedAt is
+// always refreshed here rather than trusted from the caller.
+func (s *Store) UpdateMergeRequest(mr MergeRequest) error {
+ return s.db.Update(func(tx *bolt.Tx) error {
+ mr.UpdatedAt = time.Now().UTC()
+ return putJSON(tx, bucketMergeRequests, mrKey(mr.Repo, mr.Number), mr)
+ })
+}
+
+// AddMRComment appends a comment, generating an ID if it doesn't have one.
+func (s *Store) AddMRComment(c MRComment) (MRComment, error) {
+ err := s.db.Update(func(tx *bolt.Tx) error {
+ if c.ID == "" {
+ id, err := randomToken()
+ if err != nil {
+ return err
+ }
+ c.ID = id
+ }
+ c.CreatedAt = time.Now().UTC()
+ return putJSON(tx, bucketMRComments, mrCommentPrefix(c.Repo, c.Number)+c.ID, c)
+ })
+ return c, err
+}
+
+// ListMRComments returns a merge request's comments, oldest first.
+func (s *Store) ListMRComments(repo string, number int) ([]MRComment, error) {
+ var out []MRComment
+ err := s.db.View(func(tx *bolt.Tx) error {
+ var err error
+ out, err = listJSONPrefix[MRComment](tx, bucketMRComments, mrCommentPrefix(repo, number))
+ return err
+ })
+ sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
+ return out, err
+}
internal/store/store.go
diff --git a/internal/store/store.go b/internal/store/store.go
index 5e0b4df..450b5f8 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -26,6 +26,9 @@ var (
bucketRevoked = []byte("revoked") // revoked principals / cert keys, consulted during SSH cert auth
bucketPins = []byte("pins") // principal -> bookmarked remote repos, self-service only
bucketNotifs = []byte("notifs") // principal -> inbound federated notifications
+
+ bucketMergeRequests = []byte("merge_requests") // repo -> merge requests proposing branch merges within it
+ bucketMRComments = []byte("mr_comments") // (repo, number) -> discussion thread
)
type Store struct {
@@ -40,7 +43,7 @@ func Open(path string) (*Store, error) {
return nil, fmt.Errorf("store: open %s: %w", path, err)
}
err = db.Update(func(tx *bolt.Tx) error {
- for _, b := range [][]byte{bucketUsers, bucketRepos, bucketACL, bucketTrust, bucketMeta, bucketAudit, bucketAuth, bucketSessions, bucketRevoked, bucketPins, bucketNotifs} {
+ for _, b := range [][]byte{bucketUsers, bucketRepos, bucketACL, bucketTrust, bucketMeta, bucketAudit, bucketAuth, bucketSessions, bucketRevoked, bucketPins, bucketNotifs, bucketMergeRequests, bucketMRComments} {
if _, err := tx.CreateBucketIfNotExists(b); err != nil {
return err
}