diff --git a/internal/provider/gitea_test.go b/internal/provider/gitea_test.go index 495e004..a71702f 100644 --- a/internal/provider/gitea_test.go +++ b/internal/provider/gitea_test.go @@ -1,6 +1,7 @@ package provider import ( + "context" "crypto/hmac" "crypto/sha256" "encoding/hex" @@ -134,3 +135,46 @@ func TestLoadGiteaConfigsValidatesAndRejectsDuplicates(t *testing.T) { t.Fatal("expected missing-field rejection") } } + +// Orchestra opens pull requests itself. The Gitea issues endpoint returns them +// alongside issues, so a poller that accepts them ingests its own submission +// back as a task whose description is the submission packet, and that task +// submits again. Seen live on 2026-08-28: pull request #8 became task +// 06G4E83E4KRXM8DS90M2648MGM. +func TestPollIgnoresPullRequests(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{ + {"number": 7, "title": "a real issue", "body": "", "state": "open"}, + {"number": 8, "title": "orchestra submission", "body": "", "state": "open", + "pull_request": map[string]any{"merged": false}}, + }) + })) + defer srv.Close() + + sk := &sink{} + n, err := (Gitea{BaseURL: srv.URL, Owner: "kami", Repo: "test-e2e"}).Poll(context.Background(), sk) + if err != nil { + t.Fatalf("poll: %v", err) + } + if n != 1 || len(sk.events) != 1 { + t.Fatalf("ingested %d events (reported %d), want exactly the issue", len(sk.events), n) + } +} + +func TestIngestWebhookIgnoresAPullRequestDelivery(t *testing.T) { + g := Gitea{Owner: "kami", Repo: "test-e2e", WebhookSecret: "s3cret"} + body, _ := json.Marshal(map[string]any{ + "action": "opened", + "pull_request": map[string]any{"number": 8, "title": "orchestra submission", "state": "open"}, + }) + mac := hmac.New(sha256.New, []byte("s3cret")) + mac.Write(body) + + sk := &sink{} + if err := g.IngestWebhook(body, hex.EncodeToString(mac.Sum(nil)), sk); err != nil { + t.Fatalf("ingest: %v", err) + } + if len(sk.events) != 0 { + t.Fatalf("expected no task from a pull request delivery, got %d", len(sk.events)) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 83dbfd4..111fe12 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -283,10 +283,26 @@ type giteaIssue struct { 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"` + 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"` @@ -377,6 +393,9 @@ func (g Gitea) IngestWebhook(body []byte, signature string, sink Sink) error { 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 @@ -444,12 +463,17 @@ func (g Gitea) Poll(ctx context.Context, sink Sink) (int, error) { 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 len(issues), nil + return ingested, nil } func (g Gitea) Reflect(e domain.Event) error { if e.Type != "TaskCompleted" && e.Type != "TaskBlocked" && e.Type != "TaskFailed" {