diff --git a/api/tangled/ciqueryPipelines.go b/api/tangled/ciqueryPipelines.go --- a/api/tangled/ciqueryPipelines.go +++ b/api/tangled/ciqueryPipelines.go @@ -26,9 +26,10 @@ // // commits: Filter pipelines by commits. When provided, maximum one pipeline per commit id will be returned. // cursor: Pagination cursor +// kinds: Filter pipelines by trigger kind. When provided, pipelines matching any listed kind are returned; when omitted, every kind is returned. // limit: Maximum number of pipelines to return // repo: DID of the repository -func CiQueryPipelines(ctx context.Context, c util.LexClient, commits []string, cursor string, limit int64, repo string) (*CiQueryPipelines_Output, error) { +func CiQueryPipelines(ctx context.Context, c util.LexClient, commits []string, cursor string, kinds []string, limit int64, repo string) (*CiQueryPipelines_Output, error) { var out CiQueryPipelines_Output params := map[string]interface{}{} @@ -37,6 +38,9 @@ } if cursor != "" { params["cursor"] = cursor + } + if len(kinds) != 0 { + params["kinds"] = kinds } if limit != 0 { params["limit"] = limit diff --git a/appview/pipelines/pipelines.go b/appview/pipelines/pipelines.go --- a/appview/pipelines/pipelines.go +++ b/appview/pipelines/pipelines.go @@ -19,7 +19,6 @@ "tangled.org/core/hostutil" "tangled.org/core/idresolver" "tangled.org/core/lexutil" - "tangled.org/core/orm" "tangled.org/core/rbac" "tangled.org/core/types" @@ -88,16 +87,11 @@ } filterKind := r.URL.Query().Get("trigger") - filters := []orm.Filter{ - orm.FilterEq("p.repo_did", f.RepoDid), - } + var kinds []string switch filterKind { - case "push": - filters = append(filters, orm.FilterEq("t.kind", "push")) - case "pull_request": - filters = append(filters, orm.FilterEq("t.kind", "pull_request")) + case "push", "pull_request": + kinds = []string{filterKind} default: - // no filters otherwise, default to "all" filterKind = "all" } @@ -125,9 +119,8 @@ return } - // sh.tangled.ci.queryPipelines(repo, kind, limit=30) xrpcc := indigoxrpc.Client{Host: spindleUrl} - out, err := tangled.CiQueryPipelines(r.Context(), &xrpcc, nil, "", 30, f.RepoDid) + out, err := tangled.CiQueryPipelines(r.Context(), &xrpcc, nil, "", kinds, 30, f.RepoDid) if err != nil { l.Error("failed to fetch pipelines", "err", err) p.pages.Pipelines(w, pages.PipelinesParams{ diff --git a/appview/pulls/list.go b/appview/pulls/list.go --- a/appview/pulls/list.go +++ b/appview/pulls/list.go @@ -276,7 +276,7 @@ return m } xrpcc := &indigoxrpc.Client{Host: spindleUrl} - out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", 0, f.RepoDid) + out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, f.RepoDid) if err != nil { l.Error("failed to fetch pipelines", "err", err) return m diff --git a/appview/pulls/single.go b/appview/pulls/single.go --- a/appview/pulls/single.go +++ b/appview/pulls/single.go @@ -172,7 +172,7 @@ return m } xrpcc := &indigoxrpc.Client{Host: spindleUrl} - out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", 0, f.RepoDid) + out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, f.RepoDid) if err != nil { l.Error("failed to fetch pipelines", "err", err) return m diff --git a/appview/repo/repo_util.go b/appview/repo/repo_util.go --- a/appview/repo/repo_util.go +++ b/appview/repo/repo_util.go @@ -110,7 +110,7 @@ } xrpcc := &indigoxrpc.Client{Host: spindleUrl} - out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", 0, repo.RepoDid) + out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, repo.RepoDid) if err != nil { return nil, err } diff --git a/lexicons/ci/queryPipelines.json b/lexicons/ci/queryPipelines.json --- a/lexicons/ci/queryPipelines.json +++ b/lexicons/ci/queryPipelines.json @@ -22,6 +22,14 @@ }, "description": "Filter pipelines by commits. When provided, maximum one pipeline per commit id will be returned." }, + "kinds": { + "type": "array", + "items": { + "type": "string", + "enum": ["push", "pull_request", "manual"] + }, + "description": "Filter pipelines by trigger kind. When provided, pipelines matching any listed kind are returned; when omitted, every kind is returned." + }, "limit": { "type": "integer", "description": "Maximum number of pipelines to return", diff --git a/spindle/db/pipelines.go b/spindle/db/pipelines.go --- a/spindle/db/pipelines.go +++ b/spindle/db/pipelines.go @@ -12,7 +12,7 @@ "tangled.org/core/workflow" ) -func (d *DB) QueryPipelines(ctx context.Context, repoDid string, commits []string, cursor string, limit int) ([]*tangled.CiPipeline, string, int64, error) { +func (d *DB) QueryPipelines(ctx context.Context, repoDid string, commits []string, cursor string, kinds []string, limit int) ([]*tangled.CiPipeline, string, int64, error) { if limit <= 0 { limit = 30 } @@ -39,6 +39,15 @@ json_extract(event, '$.triggerMetadata.pullRequest.sourceSha'), json_extract(event, '$.triggerMetadata.manual.sha') ) in (` + strings.Join(placeholders, ",") + ")" + } + + if len(kinds) > 0 { + placeholders := make([]string, len(kinds)) + for i := range kinds { + placeholders[i] = "?" + args = append(args, kinds[i]) + } + query += " and json_extract(event, '$.triggerMetadata.kind') in (" + strings.Join(placeholders, ",") + ")" } if cursor != "" { diff --git a/spindle/db/pipelines_test.go b/spindle/db/pipelines_test.go new file mode 100644 --- /dev/null +++ b/spindle/db/pipelines_test.go @@ -0,0 +1,118 @@ +package db + +import ( + "context" + "encoding/json" + "slices" + "testing" + "time" + + "tangled.org/core/api/tangled" +) + +func seedPipelineEvent(t *testing.T, d *DB, rkey, repoDid, kind string, created int64) { + t.Helper() + repo := repoDid + tm := &tangled.Pipeline_TriggerMetadata{ + Kind: kind, + Repo: &tangled.Pipeline_TriggerRepo{Knot: "knot.test", RepoDid: &repo, Did: repoDid}, + } + switch kind { + case "push": + tm.Push = &tangled.Pipeline_PushTriggerData{NewSha: "sha-" + rkey, Ref: "refs/heads/main"} + case "pull_request": + tm.PullRequest = &tangled.Pipeline_PullRequestTriggerData{SourceSha: "sha-" + rkey, SourceBranch: "feature", TargetBranch: "main"} + case "manual": + tm.Manual = &tangled.Pipeline_ManualTriggerData{Sha: "sha-" + rkey} + } + raw := tangled.Pipeline{ + TriggerMetadata: tm, + Workflows: []*tangled.Pipeline_Workflow{{Name: "ci.yml"}}, + } + eventJson, err := json.Marshal(raw) + if err != nil { + t.Fatalf("marshal pipeline: %v", err) + } + if _, err := d.Exec( + `insert into events (rkey, nsid, event, created) values (?, 'sh.tangled.pipeline', ?, ?)`, + rkey, string(eventJson), created, + ); err != nil { + t.Fatalf("seed event %s: %v", rkey, err) + } +} + +func TestQueryPipelines_FilterByKind(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + repo := "did:plc:boltless" + base := time.Now().UnixNano() + + seedPipelineEvent(t, d, "p-push", repo, "push", base+1) + seedPipelineEvent(t, d, "p-pull", repo, "pull_request", base+2) + seedPipelineEvent(t, d, "p-manual", repo, "manual", base+3) + + cases := []struct { + kinds []string + wantTotal int64 + wantKinds []string + }{ + {nil, 3, []string{"manual", "pull_request", "push"}}, + {[]string{"push"}, 1, []string{"push"}}, + {[]string{"pull_request"}, 1, []string{"pull_request"}}, + {[]string{"manual"}, 1, []string{"manual"}}, + {[]string{"push", "pull_request"}, 2, []string{"pull_request", "push"}}, + } + + for _, tc := range cases { + pipelines, _, total, err := d.QueryPipelines(ctx, repo, nil, "", tc.kinds, 30) + if err != nil { + t.Fatalf("kinds=%v: QueryPipelines: %v", tc.kinds, err) + } + if total != tc.wantTotal { + t.Errorf("kinds=%v: total = %d, want %d", tc.kinds, total, tc.wantTotal) + } + var gotKinds []string + for _, p := range pipelines { + gotKinds = append(gotKinds, triggerKindOf(p)) + } + slices.Sort(gotKinds) + if !slices.Equal(gotKinds, tc.wantKinds) { + t.Errorf("kinds=%v: returned %v, want %v", tc.kinds, gotKinds, tc.wantKinds) + } + } +} + +func TestQueryPipelines_KindScopedToRepo(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + base := time.Now().UnixNano() + + seedPipelineEvent(t, d, "a-push", "did:plc:alice", "push", base+1) + seedPipelineEvent(t, d, "b-push", "did:plc:bob", "push", base+2) + + pipelines, _, total, err := d.QueryPipelines(ctx, "did:plc:alice", nil, "", []string{"push"}, 30) + if err != nil { + t.Fatalf("QueryPipelines: %v", err) + } + if total != 1 || len(pipelines) != 1 { + t.Fatalf("total=%d len=%d, want exactly alice's single push pipeline", total, len(pipelines)) + } + if pipelines[0].Repo == nil || *pipelines[0].Repo != "did:plc:alice" { + t.Errorf("returned pipeline repo = %v, want did:plc:alice", pipelines[0].Repo) + } +} + +func triggerKindOf(p *tangled.CiPipeline) string { + if p.Trigger == nil { + return "" + } + switch { + case p.Trigger.CiTrigger_Push != nil: + return "push" + case p.Trigger.CiTrigger_PullRequest != nil: + return "pull_request" + case p.Trigger.CiTrigger_Manual != nil: + return "manual" + } + return "" +} diff --git a/spindle/xrpc/ci_query_pipelines.go b/spindle/xrpc/ci_query_pipelines.go --- a/spindle/xrpc/ci_query_pipelines.go +++ b/spindle/xrpc/ci_query_pipelines.go @@ -24,6 +24,7 @@ commits := r.URL.Query()["commits"] cursor := r.URL.Query().Get("cursor") + kinds := r.URL.Query()["kinds"] limitStr := r.URL.Query().Get("limit") limit := 30 @@ -33,7 +34,7 @@ } } - pipelines, nextCursor, total, err := x.Db.QueryPipelines(r.Context(), repo, commits, cursor, limit) + pipelines, nextCursor, total, err := x.Db.QueryPipelines(r.Context(), repo, commits, cursor, kinds, limit) if err != nil { fail(xrpcerr.GenericError(err), http.StatusInternalServerError) return diff --git a/appview/pipelines/ssh/session.go b/appview/pipelines/ssh/session.go --- a/appview/pipelines/ssh/session.go +++ b/appview/pipelines/ssh/session.go @@ -53,7 +53,7 @@ } xrpcc := extlexutil.Client{Client: indigoxrpc.Client{Host: host}} - out, err := tangled.CiQueryPipelines(sess.Context(), &xrpcc, []string{sha}, "", 1, repoDID) + out, err := tangled.CiQueryPipelines(sess.Context(), &xrpcc, []string{sha}, "", nil, 1, repoDID) if err != nil || len(out.Pipelines) == 0 { l.Warn("pipeline not found", "err", err) return newErrorModel(renderer, fmt.Sprintf("pipeline not found for repo %s @ %s", repoDID, sha)), wishtea.MakeOptions(sess)