arche / commit

commit 8d9a5ff17e08301045eb9c3a9dc6d409021114399f277d79df3cb7dfaaba3e8d
change hxjvvjgw
author dewn <dewn5228@proton.me>
committer dewn <dewn5228@proton.me>
date 2026-03-12 15:24:16
phase public
parents 9484e202
signature Unsigned
forge: surface BodyConflict on issue view with two-pane resolve UI and resolve-body POST handler
internal/archesrv/handlers_issues.go [M]
--- a/internal/archesrv/handlers_issues.go
+++ b/internal/archesrv/handlers_issues.go
@@ -1,259 +1,324 @@
 package archesrv
 
 import (
 	"io"
 	"net/http"
 	"path/filepath"
 	"strings"
 
 	"arche/internal/issuedb"
 )
 
 func (s *forgeServer) openIssueDB(repoName string) (*issuedb.DB, error) {
 	dir := filepath.Join(repoPath(s.dataDir(), repoName), ".arche")
 	return issuedb.Open(dir)
 }
 
 func (s *forgeServer) openIssueDBWithStore(repoName string) (*issuedb.DB, io.Closer, error) {
 	r, err := openRepo(s.dataDir(), repoName)
 	if err != nil {
 		return nil, nil, err
 	}
 	dir := filepath.Join(repoPath(s.dataDir(), repoName), ".arche")
 	idb, err := issuedb.NewWithStore(dir, r.Store)
 	if err != nil {
 		r.Store.Close() //nolint:errcheck
 		return nil, nil, err
 	}
 	return idb, r.Store, nil
 }
 
 type srvIssuesData struct {
 	Repo   string
 	User   *User
 	Issues []issueStubView
 }
 
 type issueStubView struct {
 	ID     string
 	Title  string
 	Status string
 }
 
 func (s *forgeServer) handleRepoIssues(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	idb, err := s.openIssueDB(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer idb.Close()
 
 	stubs, err := idb.Issues.ListIssues()
 	if err != nil {
 		http.Error(w, "list issues: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	var items []issueStubView
 	for _, st := range stubs {
 		items = append(items, issueStubView{ID: st.ID, Title: st.Title, Status: st.Status})
 	}
 	s.render(w, "srv_repo_issues.html", srvIssuesData{Repo: repoName, User: user, Issues: items})
 }
 
 func (s *forgeServer) handleRepoCreateIssue(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if user == nil {
 		http.Error(w, "login required", http.StatusUnauthorized)
 		return
 	}
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	r.ParseForm() //nolint:errcheck
 	title := strings.TrimSpace(r.FormValue("title"))
 	body := r.FormValue("body")
 	if title == "" {
 		http.Error(w, "title required", http.StatusBadRequest)
 		return
 	}
 
 	idb, storeCloser, err := s.openIssueDBWithStore(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer storeCloser.Close() //nolint:errcheck
 	defer idb.Close()
 
 	id, err := idb.Issues.CreateIssue(title, body, user.Username)
 	if err != nil {
 		http.Error(w, "create issue: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+id, http.StatusFound)
 }
 
 type srvIssueData struct {
 	Repo     
+    
 string
 	User     
+ 
+   
 *User
 	ID       
+ 
+   
 string
 	Title    
+  
+  
 string
 	Status   
+  
+  
 string
 	Body     
+   
+ 
 string
 	Labels   
+    
 []string
 	Comments 
+    
 []issueCommentView
+	BodyConflict *issueBodyConflictView
+}
+
+type issueBodyConflictView struct {
+	OurEdit   string
+	TheirEdit string
 }
 
 type issueCommentView struct {
 	Author string
 	Text   string
 }
 
 func (s *forgeServer) handleRepoIssue(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	id := r.URL.Query().Get("id")
 	if id == "" {
 		http.Error(w, "id required", http.StatusBadRequest)
 		return
 	}
 
 	idb, err := s.openIssueDB(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer idb.Close()
 
 	iss, err := idb.Issues.GetIssue(id)
 	if err != nil {
 		http.NotFound(w, r)
 		return
 	}
 
 	var comments []issueCommentView
 	for _, c := range iss.Comments {
 		comments = append(comments, issueCommentView{Author: c.Author, Text: c.Text})
 	}
 
+	var bodyConflict *issueBodyConflictView
+	if iss.BodyConflict != nil {
+		bodyConflict = &issueBodyConflictView{
+			OurEdit:   iss.BodyConflict.OurEdit,
+			TheirEdit: iss.BodyConflict.TheirEdit,
+		}
+	}
+
 	s.render(w, "srv_repo_issue.html", srvIssueData{
 		Repo:     
+    
 repoName,
 		User:     
+ 
+   
 user,
 		ID:
+    
        iss.ID,
 		Title:    
+ 
+   
 iss.Title,
 		Status:
+    
    iss.Status,
 		Body:     
+ 
+   
 iss.Body,
 		Labels:
+    
    iss.Labels,
 		Comments:
+    
  comments,
+		BodyConflict: bodyConflict,
 	})
 }
 
 func (s *forgeServer) handleRepoAddComment(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if user == nil {
 		http.Error(w, "login required", http.StatusUnauthorized)
 		return
 	}
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	r.ParseForm() //nolint:errcheck
 	issueID := r.FormValue("issue_id")
 	text := strings.TrimSpace(r.FormValue("text"))
 	if issueID == "" || text == "" {
 		http.Error(w, "issue_id and text required", http.StatusBadRequest)
 		return
 	}
 
 	idb, storeCloser, err := s.openIssueDBWithStore(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer storeCloser.Close() //nolint:errcheck
 	defer idb.Close()
 
 	if err := idb.Issues.AddComment(issueID, text, user.Username); err != nil {
 		http.Error(w, "add comment
+: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
+}
+
+func (s *forgeServer) handleRepoResolveBody(w http.ResponseWriter, r *http.Request) {
+	repoName := r.PathValue("repo")
+	rec, err := s.db.GetRepo(repoName)
+	if err != nil || rec == nil {
+		http.NotFound(w, r)
+		return
+	}
+	user := s.db.currentUser(r)
+	if user == nil {
+		http.Error(w, "login required", http.StatusUnauthorized)
+		return
+	}
+	if !s.db.CanRead(rec, user) {
+		http.Error(w, "Unauthorized", http.StatusUnauthorized)
+		return
+	}
+
+	r.ParseForm() //nolint:errcheck
+	issueID := r.FormValue("issue_id")
+	choice := r.FormValue("choice")
+	if issueID == "" || (choice != "ours" && choice != "theirs") {
+		http.Error(w, "issue_id and valid choice required", http.StatusBadRequest)
+		return
+	}
+
+	idb, storeCloser, err := s.openIssueDBWithStore(repoName)
+	if err != nil {
+		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer storeCloser.Close() //nolint:errcheck
+	defer idb.Close()
+
+	iss, err := idb.Issues.GetIssue(issueID)
+	if err != nil || iss.BodyConflict == nil {
+		http.Error(w, "no body conflict on this issue", http.StatusBadRequest)
+		return
+	}
+
+	resolved := iss.BodyConflict.OurEdit
+	if choice == "theirs" {
+		resolved = iss.BodyConflict.TheirEdit
+	}
+	if err := idb.Issues.SetBody(issueID, resolved, user.Username); err != nil {
+		http.Error(w, "resolve
 : "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 
 func (s *forgeServer) handleRepoSetStatus(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if user == nil {
 		http.Error(w, "login required", http.StatusUnauthorized)
 		return
 	}
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	r.ParseForm() //nolint:errcheck
 	issueID := r.FormValue("issue_id")
 	status := r.FormValue("status")
 	if issueID == "" || status == "" {
 		http.Error(w, "issue_id and status required", http.StatusBadRequest)
 		return
 	}
 
 	idb, storeCloser, err := s.openIssueDBWithStore(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer storeCloser.Close() //nolint:errcheck
 	defer idb.Close()
 
 	if err := idb.Issues.SetStatus(issueID, status, user.Username); err != nil {
 		http.Error(w, "set status: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 : "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 
 func (s *forgeServer) handleRepoSetStatus(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if user == nil {
 		http.Error(w, "login required", http.StatusUnauthorized)
 		return
 	}
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	r.ParseForm() //nolint:errcheck
 	issueID := r.FormValue("issue_id")
 	status := r.FormValue("status")
 	if issueID == "" || status == "" {
 		http.Error(w, "issue_id and status required", http.StatusBadRequest)
 		return
 	}
 
 	idb, storeCloser, err := s.openIssueDBWithStore(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer storeCloser.Close() //nolint:errcheck
 	defer idb.Close()
 
 	if err := idb.Issues.SetStatus(issueID, status, user.Username); err != nil {
 		http.Error(w, "set status: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 : "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 
 func (s *forgeServer) handleRepoSetStatus(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if user == nil {
 		http.Error(w, "login required", http.StatusUnauthorized)
 		return
 	}
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	r.ParseForm() //nolint:errcheck
 	issueID := r.FormValue("issue_id")
 	status := r.FormValue("status")
 	if issueID == "" || status == "" {
 		http.Error(w, "issue_id and status required", http.StatusBadRequest)
 		return
 	}
 
 	idb, storeCloser, err := s.openIssueDBWithStore(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer storeCloser.Close() //nolint:errcheck
 	defer idb.Close()
 
 	if err := idb.Issues.SetStatus(issueID, status, user.Username); err != nil {
 		http.Error(w, "set status: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 : "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 
 func (s *forgeServer) handleRepoSetStatus(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if user == nil {
 		http.Error(w, "login required", http.StatusUnauthorized)
 		return
 	}
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	r.ParseForm() //nolint:errcheck
 	issueID := r.FormValue("issue_id")
 	status := r.FormValue("status")
 	if issueID == "" || status == "" {
 		http.Error(w, "issue_id and status required", http.StatusBadRequest)
 		return
 	}
 
 	idb, storeCloser, err := s.openIssueDBWithStore(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer storeCloser.Close() //nolint:errcheck
 	defer idb.Close()
 
 	if err := idb.Issues.SetStatus(issueID, status, user.Username); err != nil {
 		http.Error(w, "set status: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 : "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 
 func (s *forgeServer) handleRepoSetStatus(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if user == nil {
 		http.Error(w, "login required", http.StatusUnauthorized)
 		return
 	}
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	r.ParseForm() //nolint:errcheck
 	issueID := r.FormValue("issue_id")
 	status := r.FormValue("status")
 	if issueID == "" || status == "" {
 		http.Error(w, "issue_id and status required", http.StatusBadRequest)
 		return
 	}
 
 	idb, storeCloser, err := s.openIssueDBWithStore(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer storeCloser.Close() //nolint:errcheck
 	defer idb.Close()
 
 	if err := idb.Issues.SetStatus(issueID, status, user.Username); err != nil {
 		http.Error(w, "set status: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 : "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 
 func (s *forgeServer) handleRepoSetStatus(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if user == nil {
 		http.Error(w, "login required", http.StatusUnauthorized)
 		return
 	}
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	r.ParseForm() //nolint:errcheck
 	issueID := r.FormValue("issue_id")
 	status := r.FormValue("status")
 	if issueID == "" || status == "" {
 		http.Error(w, "issue_id and status required", http.StatusBadRequest)
 		return
 	}
 
 	idb, storeCloser, err := s.openIssueDBWithStore(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer storeCloser.Close() //nolint:errcheck
 	defer idb.Close()
 
 	if err := idb.Issues.SetStatus(issueID, status, user.Username); err != nil {
 		http.Error(w, "set status: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 : "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 
 func (s *forgeServer) handleRepoSetStatus(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if user == nil {
 		http.Error(w, "login required", http.StatusUnauthorized)
 		return
 	}
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	r.ParseForm() //nolint:errcheck
 	issueID := r.FormValue("issue_id")
 	status := r.FormValue("status")
 	if issueID == "" || status == "" {
 		http.Error(w, "issue_id and status required", http.StatusBadRequest)
 		return
 	}
 
 	idb, storeCloser, err := s.openIssueDBWithStore(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer storeCloser.Close() //nolint:errcheck
 	defer idb.Close()
 
 	if err := idb.Issues.SetStatus(issueID, status, user.Username); err != nil {
 		http.Error(w, "set status: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 : "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }
 
 func (s *forgeServer) handleRepoSetStatus(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("repo")
 	rec, err := s.db.GetRepo(repoName)
 	if err != nil || rec == nil {
 		http.NotFound(w, r)
 		return
 	}
 	user := s.db.currentUser(r)
 	if user == nil {
 		http.Error(w, "login required", http.StatusUnauthorized)
 		return
 	}
 	if !s.db.CanRead(rec, user) {
 		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 		return
 	}
 
 	r.ParseForm() //nolint:errcheck
 	issueID := r.FormValue("issue_id")
 	status := r.FormValue("status")
 	if issueID == "" || status == "" {
 		http.Error(w, "issue_id and status required", http.StatusBadRequest)
 		return
 	}
 
 	idb, storeCloser, err := s.openIssueDBWithStore(repoName)
 	if err != nil {
 		http.Error(w, "open issuedb: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	defer storeCloser.Close() //nolint:errcheck
 	defer idb.Close()
 
 	if err := idb.Issues.SetStatus(issueID, status, user.Username); err != nil {
 		http.Error(w, "set status: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 	http.Redirect(w, r, "/"+repoName+"/issue?id="+issueID, http.StatusFound)
 }

internal/archesrv/server.go [M]
--- a/internal/archesrv/server.go
+++ b/internal/archesrv/server.go
@@ -1,229 +1,230 @@
 package archesrv
 
 import (
 	"embed"
 	"encoding/hex"
 	"flag"
 	"fmt"
 	"html/template"
 	"log/slog"
 	"net/http"
 	"os"
 	"path/filepath"
 	"runtime/debug"
 	"strings"
 
 	"arche/internal/markdown"
 	"arche/internal/object"
 	"arche/internal/repo"
 )
 
 //go:embed templates/*.html
 var tmplFS embed.FS
 
 type forgeServer struct {
 	db  *DB
 	cfg Config
 	log *slog.Logger
 }
 
 func (s *forgeServer) dataDir() string { return s.cfg.Storage.DataDir }
 
 func Run() error {
 	configPath := flag.String("config", "server.toml", "path to server.toml")
 	flag.Parse()
 
 	cfg, err := LoadConfig(*configPath)
 	if err != nil {
 		return err
 	}
 
 	if err := os.MkdirAll(cfg.Storage.DataDir, 0o755); err != nil {
 		return fmt.Errorf("create data dir: %w", err)
 	}
 
 	dbPath := filepath.Join(cfg.Storage.DataDir, "server.db")
 	db, err := openDB(dbPath)
 	if err != nil {
 		return err
 	}
 	defer db.Close()
 
 	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
 	s := &forgeServer{db: db, cfg: cfg, log: logger}
 
 	for repoName, repoCfg := range cfg.Repo {
 		rec, err := db.GetRepo(repoName)
 		if err != nil || rec == nil {
 			continue
 		}
 		_ = db.SetRepoAllowShellHooks(rec.ID, repoCfg.AllowShellHooks, repoCfg.PostReceive)
 	}
 	addr := cfg.Server.ListenHTTP
 	if addr == "" {
 		addr = ":8080"
 	}
 
 	if sshAddr := cfg.Server.ListenSSH; sshAddr != "" {
 		go func() {
 			if err := s.RunSSH(sshAddr); err != nil {
 				s.log.Error("SSH listener failed", "err", err)
 			}
 		}()
 	}
 
 	if mtlsAddr := cfg.Server.ListenMTLS; mtlsAddr != "" {
 		go func() {
 			if err := s.RunMTLS(mtlsAddr, cfg.Server.TLSCert, cfg.Server.TLSKey); err != nil {
 				s.log.Error("mTLS listener failed", "err", err)
 			}
 		}()
 	}
 
 	s.log.Info("listening", "addr", "http://"+addr)
 	return http.ListenAndServe(addr, s.routes())
 }
 
 func (s *forgeServer) routes() http.Handler {
 	mux := http.NewServeMux()
 
 	mux.HandleFunc("GET /setup", s.handleSetup)
 	mux.HandleFunc("POST /setup", s.handleSetupPost)
 
 	mux.HandleFunc("GET /login", s.handleLoginPage)
 	mux.HandleFunc("POST /login", s.handleLoginPost)
 	mux.HandleFunc("GET /logout", s.handleLogout)
 
 	if s.cfg.Auth.Registration == "open" || s.cfg.Auth.Registration == "invite" {
 		mux.HandleFunc("GET /register", s.handleRegisterPage)
 		mux.HandleFunc("POST /register", s.handleRegisterPost)
 	}
 
 	mux.HandleFunc("GET /settings/keys", s.handleSettingsKeys)
 	mux.HandleFunc("POST /settings/keys", s.handleSettingsAddKey)
 	mux.HandleFunc("DELETE /settings/keys/{id}", s.handleSettingsDeleteKey)
 
 	mux.HandleFunc("GET /settings/mtls", s.handleSettingsMTLSCerts)
 	mux.HandleFunc("POST /settings/mtls", s.handleSettingsAddMTLSCert)
 	mux.HandleFunc("DELETE /settings/mtls/{id}", s.handleSettingsDeleteMTLSCert)
 
 	mux.HandleFunc("GET /settings/token", s.handleSettingsToken)
 	mux.HandleFunc("POST /settings/tokens", s.handleSettingsCreateToken)
 	mux.HandleFunc("DELETE /settings/tokens/{id}", s.handleSettingsDeleteToken)
 
 	mux.HandleFunc("POST /admin/repos", s.handleAdminCreateRepo)
 	mux.HandleFunc("DELETE /admin/repos/{name}", s.handleAdminDeleteRepo)
 
 	mux.HandleFunc("GET /admin/users", s.handleAdminUsers)
 	mux.HandleFunc("POST /admin/users", s.handleAdminCreateUser)
 	mux.HandleFunc("DELETE /admin/users/{id}", s.handleAdminDeleteUser)
 
 	mux.HandleFunc("GET /admin/invites", s.handleAdminInvites)
 	mux.HandleFunc("POST /admin/invites", s.handleAdminCreateInvite)
 	mux.HandleFunc("DELETE /admin/invites/{id}", s.handleAdminDeleteInvite)
 
 	mux.HandleFunc("/{repo}/arche/v1/", s.handleSyncProxy)
 
 	mux.HandleFunc("GET /{repo}/issues", s.handleRepoIssues)
 	mux.HandleFunc("POST /{repo}/issues", s.handleRepoCreateIssue)
 	mux.HandleFunc("GET /{repo}/issue", s.handleRepoIssue)
 	mux.HandleFunc("POST /{repo}/issue/comment", s.handleRepoAddComment)
 	mux.HandleFunc("POST /{repo}/issue/status", s.handleRepoSetStatus)
+	mux.HandleFunc("POST /{repo}/issue/resolve-body", s.handleRepoResolveBody)
 
 	mux.HandleFunc("GET /{repo}/wiki", s.handleRepoWikiList)
 	mux.HandleFunc("GET /{repo}/wiki/{title}", s.handleRepoWikiPage)
 	mux.HandleFunc("POST /{repo}/wiki/{title}", s.handleRepoWikiSave)
 
 	mux.HandleFunc("GET /{repo}/settings", s.handleRepoSettingsPage)
 	mux.HandleFunc("POST /{repo}/settings", s.handleRepoUpdateSettings)
 	mux.HandleFunc("POST /{repo}/settings/collaborators", s.handleRepoAddCollaborator)
 	mux.HandleFunc("DELETE /{repo}/settings/collaborators/{id}", s.handleRepoRemoveCollaborator)
 	mux.HandleFunc("POST /{repo}/settings/delete", s.handleRepoDeleteRepo)
 
 	mux.HandleFunc("GET /{repo}/settings/webhooks", s.handleRepoWebhooks)
 	mux.HandleFunc("POST /{repo}/settings/webhooks", s.handleRepoCreateWebhook)
 	mux.HandleFunc("DELETE /{repo}/settings/webhooks/{id}", s.handleRepoDeleteWebhook)
 	mux.HandleFunc("GET /{repo}/settings/webhooks/{id}/deliveries", s.handleWebhookDeliveries)
 	mux.HandleFunc("POST /{repo}/settings/webhooks/{id}/deliveries/{delivery}/replay", s.handleWebhookReplay)
 	mux.HandleFunc("GET /{repo}/log", s.handleRepoLog)
 	mux.HandleFunc("GET /{repo}/commit", s.handleRepoCommit)
 	mux.HandleFunc("GET /{repo}/tree", s.handleRepoTree)
 	mux.HandleFunc("GET /{repo}/file", s.handleRepoFile)
 	mux.HandleFunc("GET /{repo}/stacks", s.handleRepoStacks)
 	mux.HandleFunc("GET /{repo}/stacks/{stackid}", s.handleRepoStackDetail)
 	mux.HandleFunc("POST /{repo}/stacks/{stackid}/reviews/{changeid}", s.handleStackSetReview)
 	mux.HandleFunc("GET /{repo}", s.handleRepoHome)
 
 	mux.HandleFunc("GET /{$}", s.handleIndex)
 
 	return s.recoverMiddleware(s.logMiddleware(mux))
 }
 
 type statusRecorder struct {
 	http.ResponseWriter
 	code int
 }
 
 func (r *statusRecorder) WriteHeader(code int) {
 	r.code = code
 	r.ResponseWriter.WriteHeader(code)
 }
 
 func (s *forgeServer) logMiddleware(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK}
 		next.ServeHTTP(rec, r)
 		s.log.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.code)
 	})
 }
 
 func (s *forgeServer) recoverMiddleware(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		defer func() {
 			if rec := recover(); rec != nil {
 				s.log.Error("handler panic",
 					"panic", rec,
 					"method", r.Method,
 					"path", r.URL.Path,
 					"stack", string(debug.Stack()),
 				)
 				http.Error(w, "internal server error", http.StatusInternalServerError)
 			}
 		}()
 		next.ServeHTTP(w, r)
 	})
 }
 
 func (s *forgeServer) render(w http.ResponseWriter, page string, data any) {
 	funcs := template.FuncMap{
 		"markdown":         markdown.Render,
 		"registrationOpen": func() bool { return s.cfg.Auth.Registration == "open" },
 	}
 	t, err := template.New("").Funcs(funcs).ParseFS(tmplFS,
 		"templates/srv_base.html",
 		"templates/"+page,
 	)
 	if err != nil {
 		http.Error(w, "template: "+err.Error(), http.StatusInternalServerError)
 		return
 	}
 
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 	if err := t.ExecuteTemplate(w, page, data); err != nil {
 		s.log.Error("render failed", "page", page, "err", err)
 	}
 }
 
 func fullHex(id [32]byte) string       { return hex.EncodeToString(id[:]) }
 func shortHex(id [32]byte) string      { return hex.EncodeToString(id[:])[:8] }
 func phaseClass(p object.Phase) string { return strings.ToLower(p.String()) }
 
 func bookmarkMap(r *repo.Repo) map[string][]string {
 	bms, _ := r.Store.ListBookmarks()
 	m := make(map[string][]string, len(bms))
 	for _, b := range bms {
 		k := fullHex(b.CommitID)
 		m[k] = append(m[k], b.Name)
 	}
 	return m
 }

internal/archesrv/templates/srv_repo_issue.html [M]
--- a/internal/archesrv/templates/srv_repo_issue.html
+++ b/internal/archesrv/templates/srv_repo_issue.html
@@ -1,94 +1,226 @@
 {{ define "title" }}{{.Repo}} — issue {{.ID}}{{ end }}
-
+ 
 {{ define "navextra" }}<a
+
+ 
  href="/{{.Repo}}"
+
+  
 >{{.Repo}}</a
+
->
+>
- 
+
 <a href="/{{.Repo}}/log">log</a> <a href="/{{.Repo}}/tree">tree</a>
- 
+
 <a href="/{{.Repo}}/issues">issues</a> <a href="/{{.Repo}}/stacks">stacks</a>
- 
+
 <a href="/{{.Repo}}/wiki">wiki</a>{{ end }}
-
+ 
 {{ define "srv_repo_issue.html" }}{{
- 
+
 template "head" . }}
 <div class="container">
-  
   <div style="margin-bottom: 8px">
     
-    
 <a href="/{{.Repo}}/issues" style="font-size: 13px; color: #666"
+
+      
 >← issues</a
->
 
     
+>
+  
 </div>
-  
   <h1 class="repo-name" style="margin-bottom: 6px">{{.Title}}</h1>
- 
   
- 
 <div
+
+   
  class="repo-meta"
-     
     style="display: flex;
-
-               
  align-items: center;
-
-               
- gap: 10px;
+ gap: 10px;
-
-               
  margin-bottom: 16px"
->
 
   
-  
+>
     <span>
       
-      
 {{ if eq .Status "open" }}
-      
       <span class="badge badge-public">open</span>
- 
       
-     
 {{ else if eq .Status "closed" }}
-      
       <span class="badge badge-private">closed</span>
-  
       {{ else }}
       
-      
 <span class="badge badge-secret">{{.Status}}</span>
       
-      
 {{ end }}
- 
     
-   
 </span>
-    
     <span style="color: #888">{{.ID}}</span>
-    
     {{ if .Labels }} {{ range .Labels }}<span class="bookmark-tag">{{.}}</span
+
+    
 >{{ end }} {{ end }} {{ if .User }}
-    
     <form method="post" action="/{{.Repo}}/issue/status" style="margin: 0">
-     
       
- 
 <input type="hidden" name="issue_id" value="{{.ID}}" />
       
-      
 {{ if eq .Status "open" }}
-      
       <input type="hidden" name="status" value="closed" />
       
+<button
+        type="submit"
+        style="background: #c0392b; font-size: 12px; padding: 2px 10px"
+      >
+        Close
       <
+/
 button
+>
+      {{ else }}
+      <input type="hidden" name="status" value="open" />
+      <button
+       
  type="submit"
         
+style="background:
  
+#27ae60;
  
+font-size:
  
+12px;
  
+padding:
  
+2px
  
+10px"
       
+>
+        Reopen
+      </button>
+      {{ end }}
+    </form>
+    {{ end }}
+  </div>
+  {{ if .BodyConflict }}
+  <div
+    
 style="
+
+      border: 2px solid #e67e22;
+      border-radius: 3px;
+      margin-bottom: 24px;
+      
 background: #
+fef9e
 c
-039
+;
+    "
+  >
+    <div
+      style="
+        background: #e67e2
 2
-b
 ;
         
+color:
  
+#fff;
         
+padding:
  
+6px
  
+14px;
         font-size: 1
-2
+3
 px;
         
+font-weight:
  
+bold;
       
+"
     
+>
       
+⚠
  
+Body conflict — two concurrent edits exist. Choose one to resolve.
+    </div>
+    <div style="display: flex; gap: 0">
+      <div style="flex: 1; border-right: 1px solid #e0c08a;
  padding: 
+1
 2px 1
-0
+4
 px">
-C
+
+        <div
+          style="
+            font-size: 11px;
+            font-weight: bo
 l
+d;
+            c
 o
+lor: #888;
+            margin-bottom: 8px;
+            text-transform: upperca
 se
+;
+          "
+        >
+          Our version
+        
 </
-bu
+div>
+        <pre
+          s
 t
+yle="
+            margin: 0;
+            whi
 t
+e-space: pre-wrap;
+            f
 on
+t-size: 13px;
+            line-height: 1.5;
+          "
+        >
+{{.BodyConflict.OurEdit}}</pre
+        
 >
         {{ 
+if .Us
 e
+r }}
+        <form
+          method="post"
+          action="/{{.Repo}}/issue/reso
 l
+ve-body"
+          
 s
+tyl
 e
+="margin-top:
  
-}}
+10px"
 
   
+      >
           <input type="hidden" name="
+i
 s
-tat
+s
 u
-s
+e_id
 " value="
-o
+{{.ID}}" />
+          <in
 p
+ut type="hidd
 en" 
+name="choice" value="ours" 
 />
- 
           
- 
 <button
+
+           
  type="submit"
             
-        
 style="background: #27ae60;
+ font-size: 12px; padding: 3px 12px"
 
           
+>
            
+ Use ours
+          </button>
+        </form>
+        {{ end }}
+      </div>
+      <div style="flex: 1; padding: 12px 14px">
+        <div
+          style="
+      
       font-size: 1
-2
+1
 px;
             
+font-weight:
  
+bold;
             
+color: #888;
   
+          margin-bottom: 8
 p
+x;
+            text-tr
 a
-dd
+nsform: uppercase;
+          "
+        >
+          Their vers
 i
+o
 n
+
+        </div>
+        <pre
+          style="
+            mar
 g
+in
 : 
-2
+0;
+            white-s
 p
-x
+ace: pre-wrap;
+            font-size:
  1
-0
+3
 px
+;
+            line-height: 1.5;
+          
 "
+
+        
 >
+
+{{.BodyConflict.TheirEdit}}</pre
+        >
+        {{ if .User }}
+        <form
+          method="post"
+          action="/{{.
 Re
+po}}/issue/resolve-body"
+          style="margin-t
 op
+: 10px"
+        >
+          <input type="hidd
 en
+" name="issue_id" value="{{.ID}}" />
+          
 <
+input type="hidden" name="choice" value="theirs" 
 /
+>
+          <
 button
->
 
             
-{{
+type="submit"
+           
  
+styl
 e
+="backgrou
 nd
+: #2980b9; font-size: 12px; padding: 3px 12px"
+          >
+            Use theirs
+      
  
-}}
+   </button>
 
         </form>
         {{ end }}
     
+  
 </div>
     
+</div>
+  </div>
+  
 {{
+ else
  if .Body }}
   
-  
 <div
+
+   
  style="
+
+      
 background: #f8f8f8;
       
-          
 border: 1px solid #ddd;
-          
       border-radius: 3px;
-     
       
-     
 padding: 12px 16px;
       
-          
 margin-bottom: 24px;
-          
       white-space: pre-wrap;
       
-          
 font-size: 13px;
-          
       line-height: 1.5
+;
+    
 "
+
+  
 >
+
+    
 {{.Body}}
+
+  
 </div>
-  
   {{ end }} {{ if .Comments }}
   
-  
 <h3 style="font-size: 14px; color: #555; margin-bottom: 10px">Comments</h3>
   
-  
 {{ range .Comments }}
   
-  
 <div
+
+   
  style="border: 1px solid #e0e0e0;
-
-               
  border-radius: 3px;
-
-               
  margin-bottom: 12px"
->
 
   
-  
+>
     <div
+
+     
  style="
+
+        
 background: #f4f4f4;
         
-            
 padding: 6px 12px;
-            
         font-size: 12px;
-            
         color: #555;
-            
         border-bottom: 1px solid #e0e0e0
-">
+;
 
       
+"
+    >
       <strong>{{.Author}}</strong>
     
-    
 </div>
- 
     
-   
 <div
+
+     
  style="
+
+        
 padding: 10px 12px;
         
-            
 white-space: pre-wrap;
         
-            
 font-size: 13px;
         
-            
 line-height: 1.5
+;
+      
 "
+
+    
 >
+
+      
 {{.Text}}
-</div>
 
     </div>
   
+</div>
   {{ end }} {{ end }} {{ if .User }}
   
-  
 <h3
+
+   
  style="font-size: 14px;
-
-              
  color: #555;
-
-              
  margin-bottom: 10px;
-
-              
  margin-top: 24px"
+
+  
 >
+
+    
 Add comment
+
+  
 </h3>
- 
   
- 
 <form method="post" action="/{{.Repo}}/issue/comment">
-  
     
-  
 <input type="hidden" name="issue_id" value="{{.ID}}" />
- 
     
-   
 <div class="field">
       
-      
 <textarea
+
+       
  name="text"
         
-              
 rows="4"
-       
         
-       
 required
         
-    
+style="
           
-style="
 width: 500px;
           
-                   
 padding: 6px 8px;
-                   
           border: 1px solid #ccc;
           
-                   
 border-radius: 3px;
-                   
           font-family: monospace;
           
-                   
 font-size: 13px
-"
+;
 
-    
         
-  
+"
         placeholder="Leave a comment…"
+
+      
 ></textarea>
-  
     
-  
 </div>
-    
     <button type="submit">Comment</button>
   
-  
 </form>
-  
   {{ end }}
 </div>
 {{ template "foot" . }}
-