Gitfed
bastien-mrq/gitfed/ Commits/ 0db25f1

Add merge requests: propose, review, and merge branches within a repo

New store.MergeRequest/MRComment types (internal/store/merge_requests.go), git-level plumbing in internal/gitexec (ListBranches, BranchDiff, CheckMergeable, MergeBranches), wired through admin.Ops/adminrpc the same way commits already are, and three new pages (list, create, detail) at /repo-mrs, /repo-mr-new, /repo-mr. The diff is always computed live from the two branches' current tips — never stored — so a new push to the source branch is reflected with no extra bookkeeping. Merging runs in a scratch git worktree (never touches a real branch's checked-out state) and updates the target ref with a compare-and-swap so a concurrent push can't be silently discarded. Conflicts are detected before merging and block it with the specific files listed, without touching any ref. Gated by the same RoleWrite CheckAccess a git push over SSH already requires — the web UI action doesn't accept any git objects over the network, it only combines branches whose content already arrived via a real SSH-authenticated push.

bastien-mrq 2026-07-28 22:24 commit 0db25f19d6015fc687b1af8ea2ced463c7d41cd0 parent 1a61cce7f11b70ba20d102aedb4565147503dfd7
15 files changed +1525 −6
M CHANGELOG.md +4 −0
A cmd/gitfed-web/handlers_merge_requests.go +487 −0
M cmd/gitfed-web/handlers_repo.go +14 −0
M cmd/gitfed-web/render.go +56 −0
M cmd/gitfed-web/routes.go +7 −0
M internal/admin/admin.go +117 −0
M internal/adminrpc/client.go +61 −0
M internal/adminrpc/protocol.go +74 −0
M internal/adminrpc/server.go +79 −0
M internal/gitexec/gitexec.go +208 −5
M internal/gitexec/gitexec_test.go +204 −0
M internal/i18n/strings_en.go +33 −0
M internal/i18n/strings_fr.go +33 −0
A internal/store/merge_requests.go +144 −0
M internal/store/store.go +4 −1
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 }