diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index 2294b32..d3a3f18 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -410,8 +410,12 @@ func main() { os.Exit(1) } auditSink := buildAuditSink(ctx, brainDir, logger) + // The Gitea client also satisfies SummaryWriter (#66): session + // summaries are written to mathias/ai-sessions over the same API + // token. nil only if a future tracker impl lacks file writes. + summaryWriter, _ := tracker.(capture.SummaryWriter) captureSvc := capture.NewService( - mcpSrv.BrainStore(), tracker, nil, classCfg, auditSink) + mcpSrv.BrainStore(), tracker, summaryWriter, classCfg, auditSink) sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS")) resolver := capturehttp.NewOriginResolver(sovereign) captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver) diff --git a/ingestion/internal/gitea/gitea.go b/ingestion/internal/gitea/gitea.go index d54d1a7..a3a87e6 100644 --- a/ingestion/internal/gitea/gitea.go +++ b/ingestion/internal/gitea/gitea.go @@ -12,6 +12,7 @@ package gitea import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -93,37 +94,105 @@ func (c *Client) CloseIssue(ctx context.Context, repo string, number int, commen 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)) +// WriteFile creates or updates a file in repo at path via the Gitea +// contents API — the SummaryWriter port (#66). It upserts: a GET resolves +// the current blob sha (if any) so an existing file is updated rather than +// rejected (the richer-fidelity-supersedes rule for re-captured sessions). +// Owner is the fixed const, like every other call. +func (c *Client) WriteFile(ctx context.Context, repo, path, content string) error { + cpath := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path) + sha, err := c.fileSHA(ctx, cpath) 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) + payload := map[string]any{ + "message": "capture: " + path, + "content": base64.StdEncoding.EncodeToString([]byte(content)), + } + // Gitea contents API: POST creates a new file, PUT updates an existing + // one (PUT requires the current sha). Pick by whether the file exists. + method := http.MethodPost + if sha != "" { + method = http.MethodPut + payload["sha"] = sha + } + status, body, err := c.request(ctx, method, cpath, payload) if err != nil { - return fmt.Errorf("gitea %s %s: %w", method, path, err) + return err } - defer func() { _ = resp.Body.Close() }() + if status < 200 || status >= 300 { + return fmt.Errorf("gitea %s %s: status %d: %s", method, cpath, status, strings.TrimSpace(string(body))) + } + return nil +} - 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))) +// fileSHA returns the current blob sha for a contents path, or "" when the +// file does not exist (404). Any other non-2xx is an error. +func (c *Client) fileSHA(ctx context.Context, cpath string) (string, error) { + status, body, err := c.request(ctx, http.MethodGet, cpath, nil) + if err != nil { + return "", err } - if out != nil && len(respBody) > 0 { - if err := json.Unmarshal(respBody, out); err != nil { + if status == http.StatusNotFound { + return "", nil + } + if status < 200 || status >= 300 { + return "", fmt.Errorf("gitea GET %s: status %d: %s", cpath, status, strings.TrimSpace(string(body))) + } + var meta struct { + SHA string `json:"sha"` + } + if err := json.Unmarshal(body, &meta); err != nil { + return "", fmt.Errorf("gitea GET %s: decode: %w", cpath, err) + } + return meta.SHA, nil +} + +// do performs a JSON request against the Gitea API and decodes a 2xx +// 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 { + status, body, err := c.request(ctx, method, path, payload) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("gitea %s %s: status %d: %s", method, path, status, strings.TrimSpace(string(body))) + } + if out != nil && len(body) > 0 { + if err := json.Unmarshal(body, out); err != nil { return fmt.Errorf("gitea %s %s: decode response: %w", method, path, err) } } return nil } + +// request is the shared HTTP path: marshals an optional JSON payload, +// attaches auth (token only ever in the header), and returns the status + +// body so callers can branch on status (e.g. 404) without it being an +// error. Never logs the token. +func (c *Client) request(ctx context.Context, method, path string, payload any) (int, []byte, error) { + var reader io.Reader + if payload != nil { + reqBody, err := json.Marshal(payload) + if err != nil { + return 0, nil, fmt.Errorf("marshal request: %w", err) + } + reader = bytes.NewReader(reqBody) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "token "+c.token) + + resp, err := c.http.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("gitea %s %s: %w", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) + return resp.StatusCode, body, nil +} diff --git a/ingestion/internal/gitea/gitea_test.go b/ingestion/internal/gitea/gitea_test.go index 989dd4b..3acca29 100644 --- a/ingestion/internal/gitea/gitea_test.go +++ b/ingestion/internal/gitea/gitea_test.go @@ -115,3 +115,69 @@ func TestErrorPathDoesNotLeakToken(t *testing.T) { assert.NotContains(t, err.Error(), testToken, "token must never appear in an error message") assert.Contains(t, err.Error(), "500") } + +func TestWriteFileCreatesNewFile(t *testing.T) { + var getPath, postPath, postBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + getPath = r.URL.Path + w.WriteHeader(http.StatusNotFound) // file does not exist yet + case http.MethodPost: // gitea contents API: POST = create + postPath = r.URL.Path + b, _ := io.ReadAll(r.Body) + postBody = string(b) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}}) + default: + t.Errorf("create must POST, got %s", r.Method) + } + })) + defer srv.Close() + + err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), + "ai-sessions", "summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", "# Summary\n\nbody\n") + require.NoError(t, err) + assert.Equal(t, "/api/v1/repos/mathias/ai-sessions/contents/summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", getPath) + assert.Equal(t, getPath, postPath) + // base64 of the content, no sha on create. + assert.Contains(t, postBody, "IyBTdW1tYXJ5") // base64("# Summary") + assert.NotContains(t, postBody, `"sha"`) +} + +func TestWriteFileUpdatesExisting(t *testing.T) { + var putBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"sha": "deadbeef"}) + case http.MethodPut: + b, _ := io.ReadAll(r.Body) + putBody = string(b) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}}) + } + })) + defer srv.Close() + + err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "new") + require.NoError(t, err) + assert.Contains(t, putBody, `"sha":"deadbeef"`, "existing file → update with sha") +} + +func TestWriteFileErrorNoTokenLeak(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte("bad")) + })) + defer srv.Close() + err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "x") + require.Error(t, err) + assert.NotContains(t, err.Error(), testToken) + assert.Contains(t, err.Error(), "422") +}