Stop the Gitea source ingesting Orchestra's own pull requests

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
This commit is contained in:
2026-08-28 10:51:22 +04:00
parent c11bf0eff2
commit 6ccc755999
2 changed files with 71 additions and 3 deletions
+44
View File
@@ -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))
}
}
+27 -3
View File
@@ -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" {