65daedc902
Add internal/activity (thread-safe active project + bounded event feed with subscriber fan-out). Service exposes active-project/activity methods and takes the feed. New MCP tools get_active_project/set_active_project/get_activity and HTTP endpoints GET/POST /api/active-project, /api/activity, and /events (SSE). New <activity-feed> component updates live via EventSource; <repo-list> sets the active project on selection. Verified end-to-end in the running app: user actions push live events and set the active project (actor=user), all readable by Claude over MCP. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
89 lines
3.1 KiB
Go
89 lines
3.1 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"
|
|
"path/filepath"
|
|
|
|
"gitmanager/internal/activity"
|
|
"gitmanager/internal/git"
|
|
"gitmanager/internal/repos"
|
|
)
|
|
|
|
// Service holds the shared dependencies the capabilities need.
|
|
type Service struct {
|
|
git *git.CLI
|
|
index *repos.Index
|
|
feed *activity.Feed
|
|
}
|
|
|
|
// New builds a Service over the git boundary, the scanner's repo index, and the
|
|
// activity feed.
|
|
func New(g *git.CLI, index *repos.Index, feed *activity.Feed) *Service {
|
|
return &Service{git: g, index: index, feed: feed}
|
|
}
|
|
|
|
// 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()
|
|
}
|