checkpoint: multi-repo Gitea ingestion, per-project repos, rotation anchor_sha fix

Pre-existing uncommitted work found at session start: rotation now emits
anchor_sha on TaskReleased (previously silently dropped by store.Append
validation), multi-repo Gitea provider support, per-project git worktree
roots, and associated test coverage. Committing as a checkpoint before
starting remediation work tracked in AUDIT.md.
This commit is contained in:
kami
2026-07-27 18:15:02 +04:00
parent 325c684eb0
commit ce6f02f9e6
31 changed files with 2717 additions and 320 deletions
+87 -8
View File
@@ -18,6 +18,7 @@ import (
"sync"
"time"
"orchestra/internal/authz"
"orchestra/internal/domain"
)
@@ -136,7 +137,7 @@ func (j JSONL) Ingest(r io.Reader, sink Sink) (int, error) {
return count, fmt.Errorf("line %d: %w", line, err)
}
b, _ := json.Marshal(p)
if err := sink.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: domain.NewID(), Version: 1, Payload: b}); err != nil {
if err := sink.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: domain.NewID(), Version: 1, Payload: b, Surface: string(authz.System)}); err != nil {
return count, fmt.Errorf("line %d: %w", line, err)
}
count++
@@ -197,7 +198,78 @@ func (w JSONLWatcher) Run(ctx context.Context, sink Sink) error {
type Gitea struct {
BaseURL, Token, WebhookSecret, Owner, Repo string
Client *http.Client
// Project, if set, is the orchestra project id ingested tasks are
// tagged with (registry.Project.ID) and the key this source is
// dispatched under in MultiGitea. Deployments with a single Gitea repo
// may leave it empty, in which case Repo is used as both — preserving
// the historical single-source behavior.
Project string
Client *http.Client
}
// sourceName is the provider "source" every ingested TaskCreated carries,
// and the (source,external_id) idempotency/reflection key. It is namespaced
// per project so issue numbers from two different Gitea repos never
// collide in the dedup key, and so MultiGitea can route a TaskCompleted's
// reflection back to the correct repo.
func (g Gitea) sourceName() string { return g.SourceName() }
// SourceName is the exported form of sourceName, for callers (e.g.
// cmd/orchestra) that build a MultiGitea{Sources: ...} map.
func (g Gitea) SourceName() string {
if g.Project == "" {
return "gitea"
}
return "gitea:" + g.Project
}
// GiteaSourceConfig describes one Gitea repo to ingest from/reflect to.
// Load a list of these from JSON (ORCHESTRA_GITEA_CONFIG) to run more than
// one Gitea-backed project side by side — each project may have its own
// repo, owner, and credentials.
type GiteaSourceConfig struct {
Project string `json:"project"`
BaseURL string `json:"base_url"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Token string `json:"token"`
WebhookSecret string `json:"webhook_secret"`
}
// LoadGiteaConfigs reads a JSON array of GiteaSourceConfig from path.
func LoadGiteaConfigs(path string) ([]GiteaSourceConfig, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var out []GiteaSourceConfig
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("gitea config: %w", err)
}
seen := map[string]bool{}
for _, c := range out {
if c.Project == "" || c.BaseURL == "" || c.Owner == "" || c.Repo == "" {
return nil, fmt.Errorf("gitea config: project, base_url, owner, and repo are required (got %+v)", c)
}
if seen[c.Project] {
return nil, fmt.Errorf("gitea config: duplicate project %q", c.Project)
}
seen[c.Project] = true
}
return out, nil
}
// MultiGitea dispatches TaskReflector reflection to whichever Gitea source
// ingested the task, keyed by Gitea.sourceName(). This lets several Gitea
// repos (one per project) share a single ReflectingSink.
type MultiGitea struct{ Sources map[string]Gitea }
func (m MultiGitea) ReflectTask(task domain.Task, e domain.Event) error {
g, ok := m.Sources[task.Source]
if !ok {
return nil
}
return g.ReflectTask(task, e)
}
type giteaIssue struct {
Number int `json:"number"`
@@ -229,7 +301,7 @@ func (g Gitea) event(issue giteaIssue, source, project string) domain.Event {
}
p := map[string]any{"source": source, "external_id": strconv.Itoa(issue.Number), "project": project, "title": issue.Title, "capability": caps}
b, _ := json.Marshal(p)
return domain.Event{ID: domain.NewID(), TaskID: domain.NewID(), Type: "TaskCreated", Version: 1, Payload: b}
return domain.Event{ID: domain.NewID(), TaskID: domain.NewID(), Type: "TaskCreated", Version: 1, Payload: b, Surface: string(authz.System)}
}
func (g Gitea) IngestWebhook(body []byte, signature string, sink Sink) error {
if !validSignature(body, signature, g.WebhookSecret) {
@@ -242,11 +314,14 @@ func (g Gitea) IngestWebhook(body []byte, signature string, sink Sink) error {
if h.Action == "closed" || h.Action == "deleted" {
return nil
}
project := g.Repo
if h.Repository.FullName != "" {
project = h.Repository.FullName
project := g.Project
if project == "" {
project = g.Repo
if h.Repository.FullName != "" {
project = h.Repository.FullName
}
}
return sink.Append(g.event(h.Issue, "gitea", project))
return sink.Append(g.event(h.Issue, g.sourceName(), project))
}
func validSignature(body []byte, got, secret string) bool {
if secret == "" || got == "" {
@@ -299,8 +374,12 @@ func (g Gitea) Poll(ctx context.Context, sink Sink) (int, error) {
if err = json.NewDecoder(resp.Body).Decode(&issues); err != nil {
return 0, err
}
project := g.Project
if project == "" {
project = g.Repo
}
for _, i := range issues {
if err := sink.Append(g.event(i, "gitea", g.Repo)); err != nil {
if err := sink.Append(g.event(i, g.sourceName(), project)); err != nil {
return 0, err
}
}