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:
@@ -0,0 +1,136 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"orchestra/internal/domain"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGiteaSourceNameDefaultsToRepo(t *testing.T) {
|
||||
if got := (Gitea{Repo: "orchestra"}).SourceName(); got != "gitea" {
|
||||
t.Fatalf("expected legacy unnamespaced source, got %q", got)
|
||||
}
|
||||
if got := (Gitea{Repo: "orchestra", Project: "correx"}).SourceName(); got != "gitea:correx" {
|
||||
t.Fatalf("expected namespaced source, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaIngestWebhookTagsProject(t *testing.T) {
|
||||
g := Gitea{Owner: "kami", Repo: "correx-repo", Project: "correx", WebhookSecret: "s3cret"}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"action": "opened",
|
||||
"issue": map[string]any{"number": 42, "title": "fix thing", "body": "", "state": "open"},
|
||||
})
|
||||
mac := hmac.New(sha256.New, []byte("s3cret"))
|
||||
mac.Write(body)
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
sk := &sink{}
|
||||
if err := g.IngestWebhook(body, sig, sk); err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if len(sk.events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(sk.events))
|
||||
}
|
||||
var p struct {
|
||||
Project string `json:"project"`
|
||||
Source string `json:"source"`
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
if err := json.Unmarshal(sk.events[0].Payload, &p); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if p.Project != "correx" || p.Source != "gitea:correx" || p.ExternalID != "42" {
|
||||
t.Fatalf("unexpected payload: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaIngestWebhookRejectsBadSignature(t *testing.T) {
|
||||
g := Gitea{Owner: "kami", Repo: "r", Project: "p", WebhookSecret: "s3cret"}
|
||||
if err := g.IngestWebhook([]byte(`{"action":"opened","issue":{"number":1}}`), "wrong", &sink{}); err == nil {
|
||||
t.Fatal("expected signature rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiGiteaReflectDispatchesByTaskSource(t *testing.T) {
|
||||
var hitCorrex, hitMaven bool
|
||||
correxSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hitCorrex = true
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer correxSrv.Close()
|
||||
mavenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hitMaven = true
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer mavenSrv.Close()
|
||||
|
||||
m := MultiGitea{Sources: map[string]Gitea{
|
||||
"gitea:correx": {BaseURL: correxSrv.URL, Owner: "kami", Repo: "correx-repo", Project: "correx"},
|
||||
"gitea:maven": {BaseURL: mavenSrv.URL, Owner: "kami", Repo: "maven-repo", Project: "maven"},
|
||||
}}
|
||||
|
||||
if err := m.ReflectTask(domain.Task{Source: "gitea:correx", ExternalID: "7"}, domain.Event{Type: "TaskCompleted"}); err != nil {
|
||||
t.Fatalf("reflect to correx: %v", err)
|
||||
}
|
||||
if !hitCorrex || hitMaven {
|
||||
t.Fatalf("expected only correx server hit, got correx=%v maven=%v", hitCorrex, hitMaven)
|
||||
}
|
||||
|
||||
hitCorrex, hitMaven = false, false
|
||||
if err := m.ReflectTask(domain.Task{Source: "gitea:maven", ExternalID: "3"}, domain.Event{Type: "TaskFailed"}); err != nil {
|
||||
t.Fatalf("reflect to maven: %v", err)
|
||||
}
|
||||
if hitCorrex || !hitMaven {
|
||||
t.Fatalf("expected only maven server hit, got correx=%v maven=%v", hitCorrex, hitMaven)
|
||||
}
|
||||
|
||||
// A task from a non-Gitea source (or an unregistered Gitea project) must
|
||||
// be a silent no-op, not an error.
|
||||
if err := m.ReflectTask(domain.Task{Source: "jsonl"}, domain.Event{Type: "TaskCompleted"}); err != nil {
|
||||
t.Fatalf("unmatched source should no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGiteaConfigsValidatesAndRejectsDuplicates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gitea.json")
|
||||
good := `[
|
||||
{"project":"correx","base_url":"https://gitea.internal","owner":"kami","repo":"correx"},
|
||||
{"project":"maven","base_url":"https://gitea.internal","owner":"kami","repo":"maven"}
|
||||
]`
|
||||
if err := os.WriteFile(path, []byte(good), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfgs, err := LoadGiteaConfigs(path)
|
||||
if err != nil || len(cfgs) != 2 {
|
||||
t.Fatalf("cfgs=%d err=%v", len(cfgs), err)
|
||||
}
|
||||
|
||||
dupPath := filepath.Join(dir, "dup.json")
|
||||
dup := `[
|
||||
{"project":"correx","base_url":"https://gitea.internal","owner":"kami","repo":"correx"},
|
||||
{"project":"correx","base_url":"https://gitea.internal","owner":"kami","repo":"other"}
|
||||
]`
|
||||
if err := os.WriteFile(dupPath, []byte(dup), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadGiteaConfigs(dupPath); err == nil {
|
||||
t.Fatal("expected duplicate project rejection")
|
||||
}
|
||||
|
||||
incompletePath := filepath.Join(dir, "incomplete.json")
|
||||
if err := os.WriteFile(incompletePath, []byte(`[{"project":"x"}]`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadGiteaConfigs(incompletePath); err == nil {
|
||||
t.Fatal("expected missing-field rejection")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user