Files
GitManager/cmd/server/main.go
T
TBNilles 9d1519222c Slice 5: Gitea forge - PRs and Merge & clean up
New internal/forge (provider-abstracted, Gitea impl via code.gitea.io/sdk/gitea) with tested remote-URL parsing and read+write ops: list open PRs, and merge-and-cleanup (squash-merge + delete head branch when head/base share a repo). Service resolves repo->owner/repo from remotes (prefers origin) and records a pr-merged event; config gains GITEA_URL/GITEA_TOKEN (forge disabled without both). MCP tools list_prs and merge_and_cleanup_pr (merge tool tells Claude to confirm first, 1.4). HTTP GET /api/repo/prs, POST /api/repo/pr/merge. New <pr-list> component with a confirming Merge & clean up button, hidden when no forge. Verified graceful-disabled path; real merge pending token + a designated PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:37:51 -04:00

272 lines
8.8 KiB
Go

// Command server is the GitManager entrypoint. It wires config, logging, the
// Git boundary, the repo scanner, and the Echo HTTP server, then serves the
// dashboard shell and the JSON endpoints the web components fetch from.
package main
import (
"context"
"encoding/json"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"gitmanager/internal/activity"
"gitmanager/internal/config"
"gitmanager/internal/forge"
"gitmanager/internal/git"
"gitmanager/internal/logging"
mcpserver "gitmanager/internal/mcp"
"gitmanager/internal/render"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
func main() {
cfg, err := config.Load()
if err != nil {
panic(err)
}
log, closer, err := logging.Setup(cfg.Dev, cfg.LogFile)
if err != nil {
panic(err)
}
if closer != nil {
defer closer.Close()
}
g := git.New(cfg.GitBin)
if v, err := g.Version(context.Background()); err != nil {
log.Warn("git binary not usable — repo operations will fail", "bin", cfg.GitBin, "err", err)
} else {
log.Info("git detected", "version", v)
}
// Start the read-only scanner in the background.
scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled)
scanCtx, stopScan := context.WithCancel(context.Background())
defer stopScan()
go scanner.Run(scanCtx)
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
// Coordination state: active project + activity feed (§8.2).
feed := activity.New(log, 200)
// Forge provider (Gitea) — optional; nil when unconfigured (§8.4).
fg, err := forge.NewGitea(cfg.GiteaURL, cfg.GiteaToken)
if err != nil {
log.Warn("forge disabled — invalid config", "err", err)
} else if fg != nil {
log.Info("forge enabled", "provider", "gitea", "url", cfg.GiteaURL)
} else {
log.Info("forge disabled — set GITEA_URL and GITEA_TOKEN to enable")
}
// The one service layer both the HTTP API and the MCP server call (§1.7).
svc := service.New(g, scanner.Index, feed, fg)
tmpl, err := render.New("web/templates")
if err != nil {
log.Error("failed to parse templates", "err", err)
os.Exit(1)
}
e := echo.New()
e.HideBanner = true
e.Renderer = tmpl
e.Use(middleware.Recover())
e.Use(middleware.RequestID())
// Static assets and component sources.
e.Static("/static", "web/static")
e.Static("/components", "components")
// Page shells.
e.GET("/", func(c echo.Context) error {
return c.Render(http.StatusOK, "index.html", nil)
})
e.GET("/help", func(c echo.Context) error {
return c.Render(http.StatusOK, "help.html", nil)
})
// JSON API — components self-fetch from here.
e.GET("/healthz", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
e.GET("/api/repos", func(c echo.Context) error {
return c.JSON(http.StatusOK, svc.ListRepos())
})
e.GET("/api/repo", func(c echo.Context) error {
// The service only serves details for an already-discovered repo — it
// never runs git against an arbitrary caller-supplied path (§1.3).
detail, ok := svc.RepoDetail(c.Request().Context(), c.QueryParam("path"))
if !ok {
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown repository"})
}
return c.JSON(http.StatusOK, detail)
})
// Active project + activity (§8.2).
e.GET("/api/active-project", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
})
e.POST("/api/active-project", func(c echo.Context) error {
var body struct {
Path string `json:"path"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
// A user action in the UI (actor=user) — distinct from Claude's own switches.
if _, _, err := svc.SetActiveProject(activity.ActorUser, body.Path); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
})
e.GET("/api/activity", func(c echo.Context) error {
return c.JSON(http.StatusOK, svc.Activity(0))
})
// Graceful handoff (§8.3): the user requests a switch; Claude completes it.
e.GET("/api/switch", func(c echo.Context) error {
p, ok := svc.PendingSwitch()
if !ok {
return c.JSON(http.StatusOK, map[string]any{"pending": false})
}
return c.JSON(http.StatusOK, map[string]any{
"pending": true, "target": p.Target, "note": p.Note, "requestedAt": p.RequestedAt,
})
})
e.POST("/api/switch", func(c echo.Context) error {
var body struct {
Target string `json:"target"`
Note string `json:"note"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
p, err := svc.RequestSwitch(activity.ActorUser, body.Target, body.Note)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{"pending": true, "target": p.Target, "note": p.Note})
})
e.DELETE("/api/switch", func(c echo.Context) error {
svc.CancelSwitch(activity.ActorUser)
return c.JSON(http.StatusOK, map[string]any{"pending": false})
})
// Forge PRs + "Merge & clean up" (§8.4).
e.GET("/api/repo/prs", func(c echo.Context) error {
prs, err := svc.ForgePRs(c.Request().Context(), c.QueryParam("path"))
if err != nil {
// Not configured / not on the forge host is a normal "no PRs here"
// state — tell the UI to hide the section rather than error.
if err == forge.ErrNotConfigured || err == forge.ErrNotSupported {
return c.JSON(http.StatusOK, map[string]any{"supported": false})
}
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{"supported": true, "prs": prs})
})
e.POST("/api/repo/pr/merge", func(c echo.Context) error {
var body struct {
Path string `json:"path"`
Number int64 `json:"number"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
res, err := svc.MergeAndCleanup(c.Request().Context(), activity.ActorUser, body.Path, body.Number)
if err != nil {
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, res)
})
// SSE stream of activity events for live UI (§8.2).
e.GET("/events", func(c echo.Context) error {
w := c.Response()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
w.Flush()
ch, unsub := svc.SubscribeActivity()
defer unsub()
keepalive := time.NewTicker(25 * time.Second)
defer keepalive.Stop()
for {
select {
case <-c.Request().Context().Done():
return nil
case <-keepalive.C:
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
return nil
}
w.Flush()
case ev := <-ch:
data, err := json.Marshal(ev)
if err != nil {
continue
}
if _, err := w.Write([]byte("event: activity\ndata: " + string(data) + "\n\n")); err != nil {
return nil
}
w.Flush()
}
}
})
// MCP server — Claude connects via the local stdio bridge (§8.1). Same
// service layer as the HTTP API (§1.7); localhost-bound like everything else.
mcpSrv := mcpserver.NewServer(svc, "0.1.0")
e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv)))
log.Info("mcp server mounted", "path", "/mcp")
// Serve with graceful shutdown.
go func() {
if err := e.Start(cfg.ListenAddr); err != nil && err != http.ErrServerClosed {
log.Error("server error", "err", err)
os.Exit(1)
}
}()
log.Info("listening", "addr", cfg.ListenAddr)
// Optional HTTPS listener (same Echo app). Required for the MCP connector,
// which only accepts https:// URLs (§8.1). Best-effort: a missing/unreadable
// cert logs a warning and leaves the app running over HTTP.
if cfg.HTTPSAddr != "" && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" {
if _, err := os.Stat(cfg.TLSCertFile); err != nil {
log.Warn("HTTPS requested but cert not readable — serving HTTP only", "cert", cfg.TLSCertFile, "err", err)
} else {
go func() {
if err := e.StartTLS(cfg.HTTPSAddr, cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed {
log.Error("TLS server error", "err", err)
}
}()
log.Info("listening (https)", "addr", cfg.HTTPSAddr)
}
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
log.Info("shutting down")
stopScan()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := e.Shutdown(ctx); err != nil {
log.Error("graceful shutdown failed", "err", err)
}
}