6ccc755999
The issues endpoint returns pull requests alongside issues, and nothing filtered them. The first submission this deployment ever made, kami/test-e2e#8, came straight back as task 06G4E83E4KRXM8DS90M2648MGM with the submission packet as its description. That task would have implemented, reviewed and submitted again, opening a pull request per cycle. The webhook had the same hole from the other side: a pull_request delivery leaves the issue key empty, so it would have appended a task numbered 0 with no title. Both routes now refuse a pull request, and the poll count reports what was ingested rather than what was listed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
519 lines
15 KiB
Go
519 lines
15 KiB
Go
package provider
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"orchestra/internal/authz"
|
|
"orchestra/internal/domain"
|
|
)
|
|
|
|
type Sink interface{ Append(domain.Event) error }
|
|
type Provider interface {
|
|
Ingest(io.Reader, Sink) (int, error)
|
|
}
|
|
type Reflector interface{ Reflect(domain.Event) error }
|
|
type TaskReflector interface {
|
|
ReflectTask(domain.Task, domain.Event) error
|
|
}
|
|
|
|
type TaskLookup interface {
|
|
Task(string) (domain.Task, bool)
|
|
}
|
|
|
|
// ReflectingSink preserves the append-first rule while asynchronously
|
|
// reflecting terminal state to an external provider. Reflection failures are
|
|
// returned to the caller so the supervisor can retry and surface health.
|
|
type ReflectingSink struct {
|
|
Sink Sink
|
|
Tasks TaskLookup
|
|
Reflector TaskReflector
|
|
}
|
|
|
|
func (s ReflectingSink) Append(e domain.Event) error {
|
|
if err := s.Sink.Append(e); err != nil {
|
|
return err
|
|
}
|
|
if s.Reflector == nil || s.Tasks == nil {
|
|
return nil
|
|
}
|
|
if t, ok := s.Tasks.Task(e.TaskID); ok {
|
|
return s.Reflector.ReflectTask(t, e)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Health struct {
|
|
Name string `json:"name"`
|
|
Running bool `json:"running"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Supervise restarts a provider loop with bounded backoff and exposes its
|
|
// current health. The loop exits only when its context is cancelled.
|
|
type Supervisor struct {
|
|
Name string
|
|
Run func(context.Context) error
|
|
Backoff time.Duration
|
|
mu sync.RWMutex
|
|
health Health
|
|
}
|
|
|
|
func (s *Supervisor) Start(ctx context.Context) {
|
|
if s.Backoff <= 0 {
|
|
s.Backoff = time.Second
|
|
}
|
|
s.mu.Lock()
|
|
s.health = Health{Name: s.Name, Running: true, UpdatedAt: time.Now().UTC()}
|
|
s.mu.Unlock()
|
|
go func() {
|
|
for {
|
|
err := s.Run(ctx)
|
|
if ctx.Err() != nil {
|
|
s.mu.Lock()
|
|
s.health.Running = false
|
|
s.health.UpdatedAt = time.Now().UTC()
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.health.LastError = errString(err)
|
|
s.health.UpdatedAt = time.Now().UTC()
|
|
s.mu.Unlock()
|
|
t := time.NewTimer(s.Backoff)
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Stop()
|
|
return
|
|
case <-t.C:
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func errString(err error) string {
|
|
if err == nil {
|
|
return "provider stopped"
|
|
}
|
|
return err.Error()
|
|
}
|
|
func (s *Supervisor) Health() Health { s.mu.RLock(); defer s.mu.RUnlock(); return s.health }
|
|
|
|
type JSONL struct{ Source string }
|
|
|
|
func (j JSONL) Ingest(r io.Reader, sink Sink) (int, error) {
|
|
sc := bufio.NewScanner(r)
|
|
sc.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
|
count, line := 0, 0
|
|
for sc.Scan() {
|
|
line++
|
|
raw := bytes.TrimSpace(sc.Bytes())
|
|
if len(raw) == 0 {
|
|
continue
|
|
}
|
|
var p map[string]any
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return count, fmt.Errorf("line %d: %w", line, err)
|
|
}
|
|
if j.Source != "" {
|
|
p["source"] = j.Source
|
|
}
|
|
if err := domain.ValidateCreated(p); err != nil {
|
|
return count, fmt.Errorf("line %d: %w", line, err)
|
|
}
|
|
b, _ := json.Marshal(p)
|
|
err := sink.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: domain.NewID(), Version: 1, Payload: b, Surface: string(authz.System)})
|
|
if err != nil && !errors.Is(err, domain.ErrDuplicate) {
|
|
return count, fmt.Errorf("line %d: %w", line, err)
|
|
}
|
|
if err == nil {
|
|
count++
|
|
}
|
|
}
|
|
return count, sc.Err()
|
|
}
|
|
|
|
// JSONLWatcher ingests only newly appended lines and tolerates file rotation.
|
|
type JSONLWatcher struct {
|
|
Path string
|
|
Interval time.Duration
|
|
Provider JSONL
|
|
}
|
|
|
|
func (w JSONLWatcher) Run(ctx context.Context, sink Sink) error {
|
|
if w.Interval <= 0 {
|
|
w.Interval = time.Second
|
|
}
|
|
if w.Provider.Source == "" {
|
|
w.Provider.Source = "jsonl"
|
|
}
|
|
var offset int64
|
|
for {
|
|
f, err := os.Open(w.Path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(w.Interval):
|
|
continue
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
if st, _ := f.Stat(); st.Size() < offset {
|
|
offset = 0
|
|
}
|
|
if _, err = f.Seek(offset, io.SeekStart); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
n, err := w.Provider.Ingest(f, sink)
|
|
pos, _ := f.Seek(0, io.SeekCurrent)
|
|
offset = pos
|
|
f.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_ = n
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(w.Interval):
|
|
}
|
|
}
|
|
}
|
|
|
|
type Gitea struct {
|
|
BaseURL, Token, WebhookSecret, Owner, Repo string
|
|
// 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"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
State string `json:"state"`
|
|
Labels []struct {
|
|
Name string `json:"name"`
|
|
} `json:"labels"`
|
|
// PullRequest is set by Gitea on a listing entry that is a pull request.
|
|
// The issues endpoint returns both, so without this every submission
|
|
// Orchestra makes is ingested back as a new task whose description is the
|
|
// submission packet, and that task submits again.
|
|
PullRequest *struct {
|
|
Merged bool `json:"merged"`
|
|
} `json:"pull_request"`
|
|
}
|
|
|
|
// isPullRequest reports whether this listing entry is a pull request rather
|
|
// than an issue. Orchestra opens pull requests itself; a source that accepts
|
|
// them as work is a loop.
|
|
func (i giteaIssue) isPullRequest() bool { return i.PullRequest != nil }
|
|
type giteaWebhook struct {
|
|
Action string `json:"action"`
|
|
Issue giteaIssue `json:"issue"`
|
|
// A pull_request delivery carries its payload here and leaves issue
|
|
// empty, so without this the hook would append a task numbered 0 with no
|
|
// title. Orchestra opens pull requests itself and never takes one as work.
|
|
PullRequest *giteaIssue `json:"pull_request"`
|
|
Repository struct {
|
|
FullName string `json:"full_name"`
|
|
} `json:"repository"`
|
|
}
|
|
|
|
func (g Gitea) client() *http.Client {
|
|
if g.Client != nil {
|
|
return g.Client
|
|
}
|
|
return http.DefaultClient
|
|
}
|
|
|
|
// acceptanceHeading opens the one recognized acceptance section. The
|
|
// convention is deliberately tiny: two spellings, markdown heading only. An
|
|
// ingestion that infers acceptance from arbitrary prose eventually invents
|
|
// requirements, and a fabricated acceptance criterion outranks every human
|
|
// decision below it in the authority order.
|
|
var acceptanceHeading = regexp.MustCompile(`(?i)^#{1,6}[ \t]*acceptance([ \t]+criteria)?[ \t]*:?[ \t]*$`)
|
|
|
|
// acceptanceItem matches a bullet or checklist item and captures its text.
|
|
var acceptanceItem = regexp.MustCompile(`^[ \t]*[-*+][ \t]+(?:\[[ xX]\][ \t]*)?(.*)$`)
|
|
|
|
var markdownHeading = regexp.MustCompile(`^#{1,6}[ \t]+`)
|
|
|
|
// splitAcceptance separates an issue body into the description and the
|
|
// acceptance criteria the issue stated for itself. Everything from the
|
|
// recognized heading to the next heading leaves the description, so a criterion
|
|
// is never also read as instruction prose. An empty result is not an ingestion
|
|
// failure: a task with no stated acceptance renders "Not stated." and the frame
|
|
// phase is where that gets resolved, through the decision-request path.
|
|
func splitAcceptance(body string) (string, []string) {
|
|
lines := strings.Split(body, "\n")
|
|
start := -1
|
|
for i, l := range lines {
|
|
if acceptanceHeading.MatchString(strings.TrimRight(l, " \t\r")) {
|
|
start = i
|
|
break
|
|
}
|
|
}
|
|
if start < 0 {
|
|
return body, nil
|
|
}
|
|
var acceptance []string
|
|
end := len(lines)
|
|
for i := start + 1; i < len(lines); i++ {
|
|
line := strings.TrimRight(lines[i], " \t\r")
|
|
if markdownHeading.MatchString(line) {
|
|
end = i
|
|
break
|
|
}
|
|
m := acceptanceItem.FindStringSubmatch(line)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
if item := strings.TrimSpace(m[1]); item != "" {
|
|
acceptance = append(acceptance, item)
|
|
}
|
|
}
|
|
kept := append(append([]string{}, lines[:start]...), lines[end:]...)
|
|
return strings.Trim(strings.Join(kept, "\n"), "\n"), acceptance
|
|
}
|
|
|
|
func (g Gitea) event(issue giteaIssue, source, project string) domain.Event {
|
|
caps := []string{}
|
|
for _, l := range issue.Labels {
|
|
caps = append(caps, l.Name)
|
|
}
|
|
// The body is the task's own statement of what it wants, which is rank-one
|
|
// authority in every rendered context. Dropping it here made every
|
|
// Gitea-sourced task run on its title alone, with "Acceptance: Not stated."
|
|
// Found on the first burn-in task, 2026-08-26.
|
|
description, acceptance := splitAcceptance(issue.Body)
|
|
p := map[string]any{"source": source, "external_id": strconv.Itoa(issue.Number), "project": project, "title": issue.Title, "description": description, "capability": caps}
|
|
if len(acceptance) > 0 {
|
|
p["acceptance"] = acceptance
|
|
}
|
|
b, _ := json.Marshal(p)
|
|
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) {
|
|
return errors.New("invalid webhook signature")
|
|
}
|
|
var h giteaWebhook
|
|
if err := json.Unmarshal(body, &h); err != nil {
|
|
return err
|
|
}
|
|
if h.Action == "closed" || h.Action == "deleted" {
|
|
return nil
|
|
}
|
|
if h.PullRequest != nil || h.Issue.isPullRequest() || h.Issue.Number == 0 {
|
|
return nil
|
|
}
|
|
project := g.Project
|
|
if project == "" {
|
|
project = g.Repo
|
|
if h.Repository.FullName != "" {
|
|
project = h.Repository.FullName
|
|
}
|
|
}
|
|
if err := sink.Append(g.event(h.Issue, g.sourceName(), project)); err != nil && !errors.Is(err, domain.ErrDuplicate) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
func validSignature(body []byte, got, secret string) bool {
|
|
if secret == "" || got == "" {
|
|
return false
|
|
}
|
|
got = strings.TrimPrefix(got, "sha256=")
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(body)
|
|
want := hex.EncodeToString(mac.Sum(nil))
|
|
return hmac.Equal([]byte(strings.ToLower(got)), []byte(want))
|
|
}
|
|
|
|
// WebhookHandler authenticates the request before decoding or appending it.
|
|
func (g Gitea) WebhookHandler(sink Sink) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
|
|
if err != nil {
|
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err = g.IngestWebhook(body, r.Header.Get("X-Gitea-Signature"), sink); err != nil {
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusAccepted)
|
|
})
|
|
}
|
|
func (g Gitea) Poll(ctx context.Context, sink Sink) (int, error) {
|
|
u := strings.TrimRight(g.BaseURL, "/") + "/api/v1/repos/" + g.Owner + "/" + g.Repo + "/issues?state=open"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if g.Token != "" {
|
|
req.Header.Set("Authorization", "token "+g.Token)
|
|
}
|
|
resp, err := g.client().Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
return 0, fmt.Errorf("gitea poll: %s", resp.Status)
|
|
}
|
|
var issues []giteaIssue
|
|
if err = json.NewDecoder(resp.Body).Decode(&issues); err != nil {
|
|
return 0, err
|
|
}
|
|
project := g.Project
|
|
if project == "" {
|
|
project = g.Repo
|
|
}
|
|
ingested := 0
|
|
for _, i := range issues {
|
|
if i.isPullRequest() {
|
|
continue
|
|
}
|
|
ingested++
|
|
if err := sink.Append(g.event(i, g.sourceName(), project)); err != nil && !errors.Is(err, domain.ErrDuplicate) {
|
|
return 0, err
|
|
}
|
|
}
|
|
return ingested, nil
|
|
}
|
|
func (g Gitea) Reflect(e domain.Event) error {
|
|
if e.Type != "TaskCompleted" && e.Type != "TaskBlocked" && e.Type != "TaskFailed" {
|
|
return nil
|
|
}
|
|
var p struct {
|
|
ExternalID string `json:"external_id"`
|
|
}
|
|
_ = json.Unmarshal(e.Payload, &p)
|
|
if p.ExternalID == "" {
|
|
return nil
|
|
}
|
|
u := strings.TrimRight(g.BaseURL, "/") + "/api/v1/repos/" + g.Owner + "/" + g.Repo + "/issues/" + p.ExternalID
|
|
body, _ := json.Marshal(map[string]any{"state": "closed"})
|
|
req, err := http.NewRequest(http.MethodPatch, u, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if g.Token != "" {
|
|
req.Header.Set("Authorization", "token "+g.Token)
|
|
}
|
|
resp, err := g.client().Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
return fmt.Errorf("gitea reflect: %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReflectTask is the preferred reflection entry point because lifecycle events
|
|
// identify the internal task, while the external key lives on the task.
|
|
func (g Gitea) ReflectTask(task domain.Task, e domain.Event) error {
|
|
if e.Type != "TaskCompleted" && e.Type != "TaskBlocked" && e.Type != "TaskFailed" {
|
|
return nil
|
|
}
|
|
e.Payload, _ = json.Marshal(map[string]any{"external_id": task.ExternalID})
|
|
return g.Reflect(e)
|
|
}
|