diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index 91ee40e..a91a961 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -17,6 +17,7 @@ import ( "github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher" "github.com/mathiasbq/hyperguild/ingestion/internal/embed" + "github.com/mathiasbq/hyperguild/ingestion/internal/gitea" "github.com/mathiasbq/hyperguild/ingestion/internal/graphstore" "github.com/mathiasbq/hyperguild/ingestion/internal/graphsync" "github.com/mathiasbq/hyperguild/ingestion/internal/llm" @@ -175,6 +176,15 @@ func main() { logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel) } + // Gitea ticket tracker for the capture capability (#52). Token via env + // only — never logged or in argv. Both vars must be set to enable it; + // gitea.New returns nil otherwise, leaving ticket integration off. + giteaURL := envOr("BRAIN_GITEA_URL", "https://git.d-ma.be") + if tracker := gitea.New(giteaURL, os.Getenv("BRAIN_GITEA_TOKEN")); tracker != nil { + mcpSrv = mcpSrv.WithIssueTracker(tracker) + logger.Info("brain gitea tracker configured", "url", giteaURL) + } + // Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must // be set together for the path to wire on; otherwise BM25-only. var vectorStore *vectorstore.PGStore diff --git a/ingestion/internal/gitea/gitea.go b/ingestion/internal/gitea/gitea.go new file mode 100644 index 0000000..d54d1a7 --- /dev/null +++ b/ingestion/internal/gitea/gitea.go @@ -0,0 +1,129 @@ +// Package gitea implements capture.IssueTracker against a Gitea instance +// over its REST API. It is the new outbound dependency the brain server +// gains for the capture capability (#49c/#52): the server otherwise does +// brain-local file ops only. +// +// Owner is hard-coded to the operator and never taken from caller input. +// The API token is read once at construction, held in the struct, and +// never logged or placed in argv — it travels only in the Authorization +// header of outbound requests (AGENTS.md secret-handling). +package gitea + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/mathiasbq/hyperguild/ingestion/internal/capture" +) + +// owner is the fixed repository owner for every ticket operation. It is a +// constant, not a parameter, so a caller can never redirect a write to +// another owner's repo. +const owner = "mathias" + +// Client is a Gitea REST API IssueTracker. +type Client struct { + baseURL string + token string + http *http.Client +} + +// New constructs a Client. It returns nil when either baseURL or token is +// empty, so callers can treat missing config as "tracker disabled" with a +// single nil check (mirrors embed.New). +func New(baseURL, token string) *Client { + if baseURL == "" || token == "" { + return nil + } + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// issueResponse is the subset of a Gitea issue/comment payload we read. +type issueResponse struct { + Number int `json:"number"` + HTMLURL string `json:"html_url"` +} + +// CreateIssue opens a new issue under the fixed owner. +func (c *Client) CreateIssue(ctx context.Context, repo, title, body string) (capture.IssueRef, error) { + var out issueResponse + if err := c.do(ctx, http.MethodPost, + fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo), + map[string]any{"title": title, "body": body}, &out); err != nil { + return capture.IssueRef{}, err + } + return capture.IssueRef{Repo: repo, Number: out.Number, URL: out.HTMLURL}, nil +} + +// CommentIssue posts a comment on an existing issue. +func (c *Client) CommentIssue(ctx context.Context, repo string, number int, body string) (capture.IssueRef, error) { + var out issueResponse + if err := c.do(ctx, http.MethodPost, + fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number), + map[string]any{"body": body}, &out); err != nil { + return capture.IssueRef{}, err + } + return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil +} + +// CloseIssue closes an issue, first posting a closing comment when one is +// given (empty comment ⇒ close only). +func (c *Client) CloseIssue(ctx context.Context, repo string, number int, comment string) (capture.IssueRef, error) { + if strings.TrimSpace(comment) != "" { + if _, err := c.CommentIssue(ctx, repo, number, comment); err != nil { + return capture.IssueRef{}, err + } + } + var out issueResponse + if err := c.do(ctx, http.MethodPatch, + fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number), + map[string]any{"state": "closed"}, &out); err != nil { + return capture.IssueRef{}, err + } + return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil +} + +// do performs a JSON request against the Gitea API and decodes the +// response into out. Errors carry the status and a truncated body for +// diagnosis but never the token. +func (c *Client) do(ctx context.Context, method, path string, payload any, out *issueResponse) error { + reqBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(reqBody)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + // Gitea's token scheme. Held here only; never logged. + req.Header.Set("Authorization", "token "+c.token) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("gitea %s %s: %w", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gitea %s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBody))) + } + if out != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("gitea %s %s: decode response: %w", method, path, err) + } + } + return nil +} diff --git a/ingestion/internal/gitea/gitea_test.go b/ingestion/internal/gitea/gitea_test.go new file mode 100644 index 0000000..989dd4b --- /dev/null +++ b/ingestion/internal/gitea/gitea_test.go @@ -0,0 +1,117 @@ +package gitea_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/gitea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testToken = "super-secret-token-value" + +func TestNewNilWhenUnconfigured(t *testing.T) { + assert.Nil(t, gitea.New("", testToken)) + assert.Nil(t, gitea.New("https://git.example", "")) +} + +func TestCreateIssueForcesOwnerAndAuth(t *testing.T) { + var gotPath, gotAuth, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"number": 42, "html_url": "https://git.d-ma.be/mathias/hyperguild/issues/42"}) + })) + defer srv.Close() + + c := gitea.New(srv.URL, testToken) + require.NotNil(t, c) + ref, err := c.CreateIssue(context.Background(), "hyperguild", "Do the thing", "details") + require.NoError(t, err) + + assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues", gotPath, "owner forced to mathias") + assert.Equal(t, "token "+testToken, gotAuth) + assert.Contains(t, gotBody, "Do the thing") + assert.Equal(t, "hyperguild", ref.Repo) + assert.Equal(t, 42, ref.Number) + assert.Contains(t, ref.URL, "/issues/42") +} + +func TestCommentIssue(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"html_url": "https://git/c/1"}) + })) + defer srv.Close() + + ref, err := gitea.New(srv.URL, testToken).CommentIssue(context.Background(), "hyperguild", 7, "a comment") + require.NoError(t, err) + assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues/7/comments", gotPath) + assert.Equal(t, 7, ref.Number) +} + +func TestCloseIssueWithComment(t *testing.T) { + var paths []string + var states []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.Method+" "+r.URL.Path) + if r.Method == http.MethodPatch { + var body map[string]any + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &body) + states = append(states, body["state"].(string)) + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"number": 9, "html_url": "https://git/i/9"}) + })) + defer srv.Close() + + ref, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 9, "closing because done") + require.NoError(t, err) + assert.Equal(t, 9, ref.Number) + // Comment posted first, then state PATCHed to closed. + assert.Contains(t, paths, "POST /api/v1/repos/mathias/hyperguild/issues/9/comments") + assert.Contains(t, paths, "PATCH /api/v1/repos/mathias/hyperguild/issues/9") + assert.Equal(t, []string{"closed"}, states) +} + +func TestCloseIssueNoComment(t *testing.T) { + var commented bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/comments") { + commented = true + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"number": 3, "html_url": "https://git/i/3"}) + })) + defer srv.Close() + + _, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 3, "") + require.NoError(t, err) + assert.False(t, commented, "empty comment ⇒ no comment POST") +} + +func TestErrorPathDoesNotLeakToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer srv.Close() + + _, err := gitea.New(srv.URL, testToken).CreateIssue(context.Background(), "hyperguild", "t", "b") + require.Error(t, err) + assert.NotContains(t, err.Error(), testToken, "token must never appear in an error message") + assert.Contains(t, err.Error(), "500") +} diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index b072db5..a88a21b 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -11,6 +11,7 @@ import ( "net/http" "github.com/mathiasbq/hyperguild/ingestion/internal/brainstore" + "github.com/mathiasbq/hyperguild/ingestion/internal/capture" "github.com/mathiasbq/hyperguild/ingestion/internal/graphstore" "github.com/mathiasbq/hyperguild/ingestion/internal/graphsync" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" @@ -48,6 +49,7 @@ type Server struct { embedder search.Embedder // nil = BM25-only retrieval graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled store *brainstore.Store // shared brain write/update/get impl (also used by capture) + tracker capture.IssueTracker // nil = no Gitea ticket integration; wired for capture (#53) } // NewServer constructs a Server bound to brainDir. pipelineCfg supplies the @@ -100,6 +102,20 @@ func (s *Server) WithGraph(g *graphstore.PGStore) *Server { return s } +// WithIssueTracker injects the Gitea ticket tracker behind the +// capture.IssueTracker interface. nil leaves ticket integration off. The +// use-case (capture) consumes this in #53; it is wired here so the +// dependency is constructed once and stays swappable/testable. +func (s *Server) WithIssueTracker(t capture.IssueTracker) *Server { + s.tracker = t + return s +} + +// IssueTracker returns the injected ticket tracker (nil when unconfigured). +func (s *Server) IssueTracker() capture.IssueTracker { + return s.tracker +} + func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // MCP streamable HTTP: GET establishes the SSE stream for server-to-client events. if r.Method == http.MethodGet { diff --git a/ingestion/internal/mcp/server_test.go b/ingestion/internal/mcp/server_test.go index 6d6494d..06f4bfb 100644 --- a/ingestion/internal/mcp/server_test.go +++ b/ingestion/internal/mcp/server_test.go @@ -2,12 +2,14 @@ package mcp_test import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" + "github.com/mathiasbq/hyperguild/ingestion/internal/capture" "github.com/mathiasbq/hyperguild/ingestion/internal/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -93,3 +95,22 @@ func TestServerUnknownMethodReturnsError(t *testing.T) { assert.Equal(t, float64(-32601), errObj["code"]) assert.Contains(t, errObj["message"].(string), "unknown/method") } + +type stubTracker struct{} + +func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) { + return capture.IssueRef{}, nil +} +func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) { + return capture.IssueRef{}, nil +} +func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) { + return capture.IssueRef{}, nil +} + +func TestWithIssueTrackerInjects(t *testing.T) { + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) + assert.Nil(t, srv.IssueTracker(), "tracker is off by default") + srv = srv.WithIssueTracker(stubTracker{}) + assert.NotNil(t, srv.IssueTracker(), "tracker injected behind the interface") +}