32ae17cc9f
Reduce .env to just REPOS_HOST_PATH (the projects root); runtime bootstrap moves to compose/defaults. Projects are the subdirectories of the single root — removed the project-directories feature (store table, service methods, /api/config/project-dirs, Settings section). Each project's forge is derived from its git remote (matched to a configured forge, else the bare host) and shown as a pill next to its name (State.Forge via scanner ForgeFor + svc.ForgeDisplay). Removed first-run .env seeding; forges + identity are managed in Settings. Added forge.HostOf. AGENT.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
464 lines
16 KiB
Go
464 lines
16 KiB
Go
// Package service is the ONE capability layer behind both the HTTP API and the
|
|
// MCP server (AGENT.md §1.7). HTTP handlers and MCP tool handlers are thin
|
|
// adapters that call these methods; Git/forge logic never lives in a handler.
|
|
// Everything here goes through the internal/git (and later internal/forge)
|
|
// boundaries and obeys the safety rules (§1.4).
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gitmanager/internal/activity"
|
|
"gitmanager/internal/forge"
|
|
"gitmanager/internal/git"
|
|
"gitmanager/internal/repos"
|
|
"gitmanager/internal/store"
|
|
)
|
|
|
|
// Service holds the shared dependencies the capabilities need.
|
|
type Service struct {
|
|
git *git.CLI
|
|
index *repos.Index
|
|
feed *activity.Feed
|
|
store *store.Store
|
|
log *slog.Logger
|
|
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
|
|
|
|
mu sync.Mutex // guards forgeCache
|
|
forgeCache map[string]*forge.Gitea // base_url -> provider
|
|
}
|
|
|
|
// New builds a Service over the git boundary, the scanner's repo index, the
|
|
// activity feed, the config store, and a single-repo refresh hook (may be nil)
|
|
// used to re-scan a repo after a mutating action.
|
|
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, st *store.Store, log *slog.Logger, refresh func(context.Context, string)) *Service {
|
|
return &Service{
|
|
git: g, index: index, feed: feed, store: st, log: log, refresh: refresh,
|
|
forgeCache: make(map[string]*forge.Gitea),
|
|
}
|
|
}
|
|
|
|
// ListRepos returns a snapshot of every discovered repository.
|
|
func (s *Service) ListRepos() []repos.State {
|
|
return s.index.List()
|
|
}
|
|
|
|
// GetRepo returns the cached state for one repo, or false if it is not indexed.
|
|
// The path is cleaned so separator style does not defeat the exact-match lookup.
|
|
func (s *Service) GetRepo(path string) (repos.State, bool) {
|
|
return s.index.Get(filepath.Clean(path))
|
|
}
|
|
|
|
// RepoDetail returns the enriched detail (branches, commits, remotes) for one
|
|
// repo. The second return is false when the path is not an indexed repository —
|
|
// we never run git against an arbitrary caller-supplied path (§1.3).
|
|
func (s *Service) RepoDetail(ctx context.Context, path string) (repos.Detail, bool) {
|
|
base, ok := s.index.Get(filepath.Clean(path))
|
|
if !ok {
|
|
return repos.Detail{}, false
|
|
}
|
|
return repos.BuildDetail(ctx, s.git, base), true
|
|
}
|
|
|
|
// --- Activity & active project (§8.2) --------------------------------------
|
|
|
|
// ActiveProject returns the current active project path ("" if none).
|
|
func (s *Service) ActiveProject() string {
|
|
return s.feed.ActiveProject()
|
|
}
|
|
|
|
// SetActiveProject makes path the active project (or clears it when empty). It
|
|
// rejects a path that is not an indexed repository — the active project must be
|
|
// a real repo (§1.3). Returns the recorded event and whether it changed.
|
|
func (s *Service) SetActiveProject(actor activity.Actor, path string) (activity.Event, bool, error) {
|
|
if path != "" {
|
|
path = filepath.Clean(path)
|
|
if _, ok := s.index.Get(path); !ok {
|
|
return activity.Event{}, false, fmt.Errorf("unknown repository %q", path)
|
|
}
|
|
}
|
|
ev, changed := s.feed.SetActiveProject(actor, path)
|
|
return ev, changed, nil
|
|
}
|
|
|
|
// RecordActivity appends an arbitrary event to the feed.
|
|
func (s *Service) RecordActivity(actor activity.Actor, kind, repo, detail string) activity.Event {
|
|
return s.feed.Record(actor, kind, repo, detail)
|
|
}
|
|
|
|
// Activity returns up to limit recent events, oldest first.
|
|
func (s *Service) Activity(limit int) []activity.Event {
|
|
return s.feed.Events(limit)
|
|
}
|
|
|
|
// SubscribeActivity returns a channel of future events plus an unsubscribe func
|
|
// the caller must invoke when done.
|
|
func (s *Service) SubscribeActivity() (<-chan activity.Event, func()) {
|
|
return s.feed.Subscribe()
|
|
}
|
|
|
|
// --- Graceful project handoff (§8.3) ---------------------------------------
|
|
|
|
// RequestSwitch records a user's request for Claude to switch to target. The
|
|
// target must be an indexed repository.
|
|
func (s *Service) RequestSwitch(actor activity.Actor, target, note string) (activity.PendingSwitch, error) {
|
|
target = filepath.Clean(target)
|
|
if _, ok := s.index.Get(target); !ok {
|
|
return activity.PendingSwitch{}, fmt.Errorf("unknown repository %q", target)
|
|
}
|
|
return s.feed.RequestSwitch(actor, target, note), nil
|
|
}
|
|
|
|
// PendingSwitch returns the outstanding switch request, if any.
|
|
func (s *Service) PendingSwitch() (activity.PendingSwitch, bool) {
|
|
return s.feed.PendingSwitch()
|
|
}
|
|
|
|
// AckSwitch completes the pending handoff on Claude's behalf: sets the active
|
|
// project to the requested target and records Claude's summary. Returns false if
|
|
// nothing was pending.
|
|
func (s *Service) AckSwitch(summary string) (activity.PendingSwitch, bool) {
|
|
return s.feed.AckSwitch(activity.ActorClaude, summary)
|
|
}
|
|
|
|
// CancelSwitch clears a pending switch request.
|
|
func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bool) {
|
|
return s.feed.CancelSwitch(actor)
|
|
}
|
|
|
|
// --- Forge (Gitea) — PRs and "Merge & clean up" (§8.4) ---------------------
|
|
|
|
// ForgeConfigured reports whether any forge is configured in the store.
|
|
func (s *Service) ForgeConfigured(ctx context.Context) bool {
|
|
forges, err := s.store.ListForges(ctx)
|
|
return err == nil && len(forges) > 0
|
|
}
|
|
|
|
// ForgePRs lists open pull requests for a repo. Returns forge.ErrNotConfigured
|
|
// when no forge is configured, or forge.ErrNotSupported when the repo's remote is
|
|
// not on a configured host.
|
|
func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) {
|
|
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return prov.ListPullRequests(ctx, owner, repo)
|
|
}
|
|
|
|
// CreatePR opens a pull request from head into base (empty base = the repo's
|
|
// default branch) and records the action. The head branch must already exist on
|
|
// the remote (push it first).
|
|
func (s *Service) CreatePR(ctx context.Context, actor activity.Actor, repoPath, head, base, title, body string) (forge.PullRequest, error) {
|
|
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
|
|
if err != nil {
|
|
return forge.PullRequest{}, err
|
|
}
|
|
pr, err := prov.CreatePullRequest(ctx, owner, repo, forge.NewPR{Head: head, Base: base, Title: title, Body: body})
|
|
if err != nil {
|
|
return forge.PullRequest{}, err
|
|
}
|
|
s.feed.Record(actor, "pr-created", filepath.Clean(repoPath), fmt.Sprintf("PR #%d %s → %s", pr.Number, pr.Head, pr.Base))
|
|
return pr, nil
|
|
}
|
|
|
|
// MergeAndCleanup merges a PR and deletes its branch, then records the action.
|
|
// The caller is responsible for confirming with the user first (§1.4); actor
|
|
// distinguishes a UI action (user) from an MCP one (claude).
|
|
func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, repoPath string, number int64) (forge.MergeResult, error) {
|
|
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
|
|
if err != nil {
|
|
return forge.MergeResult{}, err
|
|
}
|
|
res, err := prov.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
|
|
if err != nil {
|
|
return forge.MergeResult{}, err
|
|
}
|
|
detail := fmt.Sprintf("merged PR #%d", res.Number)
|
|
if res.BranchDeleted && res.Branch != "" {
|
|
detail += fmt.Sprintf(", deleted branch %s", res.Branch)
|
|
}
|
|
s.feed.Record(actor, "pr-merged", filepath.Clean(repoPath), detail)
|
|
return res, nil
|
|
}
|
|
|
|
// --- Git actions (the plain-language commands, §6) --------------------------
|
|
|
|
// gitAction runs one mutating git op through the boundary, records the outcome
|
|
// on the activity feed, and refreshes the repo in the index on success. Callers
|
|
// are responsible for §1.4 confirmation of destructive ops (e.g. discard).
|
|
func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, kind, okDetail string, run func(dir string) (string, error)) (string, error) {
|
|
base, ok := s.index.Get(filepath.Clean(repoPath))
|
|
if !ok {
|
|
return "", fmt.Errorf("unknown repository %q", repoPath)
|
|
}
|
|
out, err := run(base.Path)
|
|
if err != nil {
|
|
s.feed.Record(actor, kind, base.Path, "failed: "+err.Error())
|
|
return "", err
|
|
}
|
|
if okDetail == "" {
|
|
okDetail = "ok"
|
|
}
|
|
s.feed.Record(actor, kind, base.Path, okDetail)
|
|
if s.refresh != nil {
|
|
s.refresh(ctx, base.Path)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GitFetch, GitPull, GitPush, GitCommit, GitDiscard are the mutating commands
|
|
// the right-click menu (and, later, MCP) invoke.
|
|
func (s *Service) GitFetch(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
|
|
return s.gitAction(ctx, actor, repoPath, "git-fetch", "ok", func(d string) (string, error) {
|
|
return "", s.git.Fetch(ctx, d)
|
|
})
|
|
}
|
|
|
|
func (s *Service) GitPull(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
|
|
return s.gitAction(ctx, actor, repoPath, "git-pull", "ok", func(d string) (string, error) {
|
|
return s.git.Pull(ctx, d)
|
|
})
|
|
}
|
|
|
|
func (s *Service) GitPush(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
|
|
return s.gitAction(ctx, actor, repoPath, "git-push", "ok", func(d string) (string, error) {
|
|
return s.git.Push(ctx, d)
|
|
})
|
|
}
|
|
|
|
func (s *Service) GitCommit(ctx context.Context, actor activity.Actor, repoPath, message string) (string, error) {
|
|
if strings.TrimSpace(message) == "" {
|
|
return "", fmt.Errorf("a commit message is required")
|
|
}
|
|
return s.gitAction(ctx, actor, repoPath, "git-commit", "ok", func(d string) (string, error) {
|
|
return s.git.Commit(ctx, d, message)
|
|
})
|
|
}
|
|
|
|
// GitDiscard is DESTRUCTIVE (§1.4) — the caller must confirm with the user first.
|
|
func (s *Service) GitDiscard(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
|
|
return s.gitAction(ctx, actor, repoPath, "git-discard", "ok", func(d string) (string, error) {
|
|
return s.git.DiscardAll(ctx, d)
|
|
})
|
|
}
|
|
|
|
// GitCheckout switches to an existing branch.
|
|
func (s *Service) GitCheckout(ctx context.Context, actor activity.Actor, repoPath, branch string) (string, error) {
|
|
if strings.TrimSpace(branch) == "" {
|
|
return "", fmt.Errorf("a branch name is required")
|
|
}
|
|
return s.gitAction(ctx, actor, repoPath, "git-checkout", "switched to "+branch, func(d string) (string, error) {
|
|
return s.git.Checkout(ctx, d, branch)
|
|
})
|
|
}
|
|
|
|
// GitCreateBranch creates a new branch from HEAD and switches to it.
|
|
func (s *Service) GitCreateBranch(ctx context.Context, actor activity.Actor, repoPath, name string) (string, error) {
|
|
if strings.TrimSpace(name) == "" {
|
|
return "", fmt.Errorf("a branch name is required")
|
|
}
|
|
return s.gitAction(ctx, actor, repoPath, "git-create-branch", "created "+name, func(d string) (string, error) {
|
|
return s.git.CreateBranch(ctx, d, name)
|
|
})
|
|
}
|
|
|
|
// resolveForge maps a repo path to (provider, owner, repo) by matching its git
|
|
// remotes (preferring "origin") against the forges configured in the store.
|
|
func (s *Service) resolveForge(ctx context.Context, repoPath string) (*forge.Gitea, string, string, error) {
|
|
forges, err := s.store.ListForges(ctx)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
if len(forges) == 0 {
|
|
return nil, "", "", forge.ErrNotConfigured
|
|
}
|
|
base, ok := s.index.Get(filepath.Clean(repoPath))
|
|
if !ok {
|
|
return nil, "", "", fmt.Errorf("unknown repository %q", repoPath)
|
|
}
|
|
remotes, err := s.git.RemoteDetails(ctx, base.Path)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
|
|
match := func(remoteURL string) (*forge.Gitea, string, string, bool) {
|
|
host, o, r, ok := forge.ParseRemote(remoteURL)
|
|
if !ok {
|
|
return nil, "", "", false
|
|
}
|
|
for _, f := range forges {
|
|
prov, perr := s.forgeFor(f)
|
|
if perr != nil {
|
|
continue
|
|
}
|
|
if prov.Handles(host) {
|
|
return prov, o, r, true
|
|
}
|
|
}
|
|
return nil, "", "", false
|
|
}
|
|
|
|
// Prefer origin, then any matching remote.
|
|
var fb *forge.Gitea
|
|
var fo, fr string
|
|
for _, rm := range remotes {
|
|
prov, o, r, ok := match(rm.URL)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if rm.Name == "origin" {
|
|
return prov, o, r, nil
|
|
}
|
|
if fb == nil {
|
|
fb, fo, fr = prov, o, r
|
|
}
|
|
}
|
|
if fb != nil {
|
|
return fb, fo, fr, nil
|
|
}
|
|
return nil, "", "", forge.ErrNotSupported
|
|
}
|
|
|
|
// forgeFor returns a cached *forge.Gitea for a configured forge, building it on
|
|
// first use. The cache is cleared whenever forges change (invalidateForges).
|
|
func (s *Service) forgeFor(f store.Forge) (*forge.Gitea, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if p, ok := s.forgeCache[f.BaseURL]; ok {
|
|
return p, nil
|
|
}
|
|
p, err := forge.NewGitea(f.BaseURL, f.Token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if p == nil {
|
|
return nil, forge.ErrNotConfigured
|
|
}
|
|
s.forgeCache[f.BaseURL] = p
|
|
return p, nil
|
|
}
|
|
|
|
func (s *Service) invalidateForges() {
|
|
s.mu.Lock()
|
|
s.forgeCache = make(map[string]*forge.Gitea)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// --- Configuration store (forges, project dirs, git identity) — §1.3 --------
|
|
|
|
// Forges lists configured forges (tokens are never included).
|
|
func (s *Service) Forges(ctx context.Context) ([]store.Forge, error) {
|
|
return s.store.ListForges(ctx)
|
|
}
|
|
|
|
// AddForge adds a forge, then reapplies git auth so pushes to it work.
|
|
func (s *Service) AddForge(ctx context.Context, name, kind, baseURL, token string) (store.Forge, error) {
|
|
if strings.TrimSpace(baseURL) == "" {
|
|
return store.Forge{}, fmt.Errorf("a base URL is required")
|
|
}
|
|
f, err := s.store.AddForge(ctx, store.Forge{Name: name, Kind: kind, BaseURL: strings.TrimRight(baseURL, "/"), Token: token})
|
|
if err != nil {
|
|
return store.Forge{}, err
|
|
}
|
|
s.invalidateForges()
|
|
s.ApplyGitConfig(ctx)
|
|
s.feed.Record(activity.ActorUser, "forge-added", "", baseURL)
|
|
return f, nil
|
|
}
|
|
|
|
// UpdateForge edits a forge (empty token keeps the existing one).
|
|
func (s *Service) UpdateForge(ctx context.Context, id int64, name, kind, baseURL, token string) error {
|
|
if err := s.store.UpdateForge(ctx, id, name, kind, strings.TrimRight(baseURL, "/"), token); err != nil {
|
|
return err
|
|
}
|
|
s.invalidateForges()
|
|
s.ApplyGitConfig(ctx)
|
|
return nil
|
|
}
|
|
|
|
// DeleteForge removes a forge.
|
|
func (s *Service) DeleteForge(ctx context.Context, id int64) error {
|
|
if err := s.store.DeleteForge(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
s.invalidateForges()
|
|
return nil
|
|
}
|
|
|
|
// GitIdentity returns the configured commit identity.
|
|
func (s *Service) GitIdentity(ctx context.Context) (name, email string) {
|
|
name, _ = s.store.GetSetting(ctx, "git_user_name", "")
|
|
email, _ = s.store.GetSetting(ctx, "git_user_email", "")
|
|
return name, email
|
|
}
|
|
|
|
// SetGitIdentity stores the commit identity and reapplies it to git config.
|
|
func (s *Service) SetGitIdentity(ctx context.Context, name, email string) error {
|
|
if err := s.store.SetSetting(ctx, "git_user_name", name); err != nil {
|
|
return err
|
|
}
|
|
if err := s.store.SetSetting(ctx, "git_user_email", email); err != nil {
|
|
return err
|
|
}
|
|
s.ApplyGitConfig(ctx)
|
|
return nil
|
|
}
|
|
|
|
// ForgeDisplay maps a repo's origin remote URL to a label for the forge it
|
|
// belongs to: a configured forge's name (or its host) when the remote host
|
|
// matches one, otherwise the bare remote host, otherwise "". Used by the scanner
|
|
// to show each project's forge next to its name.
|
|
func (s *Service) ForgeDisplay(ctx context.Context, remoteURL string) string {
|
|
host, _, _, ok := forge.ParseRemote(remoteURL)
|
|
if !ok || host == "" {
|
|
return ""
|
|
}
|
|
forges, err := s.store.ListForges(ctx)
|
|
if err == nil {
|
|
for _, f := range forges {
|
|
if strings.EqualFold(forge.HostOf(f.BaseURL), host) {
|
|
if f.Name != "" {
|
|
return f.Name
|
|
}
|
|
return host
|
|
}
|
|
}
|
|
}
|
|
return host // not a configured forge, but still informative
|
|
}
|
|
|
|
// ApplyGitConfig writes the container's git global config from the store: a
|
|
// commit identity, safe.directory for host-owned mounts, and an auth header per
|
|
// forge so push/fetch/pull over HTTPS work. Called at startup and after changes.
|
|
func (s *Service) ApplyGitConfig(ctx context.Context) {
|
|
set := func(key, value string) {
|
|
if err := s.git.SetGlobalConfig(ctx, key, value); err != nil && s.log != nil {
|
|
s.log.Warn("git config failed", "key", key, "err", err)
|
|
}
|
|
}
|
|
set("safe.directory", "*")
|
|
if name, email := s.GitIdentity(ctx); name != "" || email != "" {
|
|
if name != "" {
|
|
set("user.name", name)
|
|
}
|
|
if email != "" {
|
|
set("user.email", email)
|
|
}
|
|
}
|
|
forges, err := s.store.ListForges(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, f := range forges {
|
|
if f.Token == "" {
|
|
continue
|
|
}
|
|
set("http."+f.BaseURL+".extraheader", "Authorization: token "+f.Token)
|
|
}
|
|
}
|