From b0f1420893f26f32b7427ab644f1ad539c8ddab3 Mon Sep 17 00:00:00 2001 From: dawn Date: Wed, 01 Jul 2026 17:00:12 +0000 Subject: [PATCH] appview,spindle,lexicons: support triggering ci for fork-based pull requests Signed-off-by: dawn --- flake.nix | 2 +- appview/ingester.go | 2 +- appview/ingester_repo.go | 2 +- appview/ingester_repo_test.go | 2 +- repoverify/verify.go | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ repoverify/verify_test.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ spindle/server.go | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------- spindle/tapclient.go | 8 ++++++-- types/pipeline.go | 27 +++++++++++++++++++++++---- api/tangled/cbor_gen.go | 715 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------ api/tangled/cipipeline.go | 2 ++ api/tangled/citrigger.go | 28 ++++++++++++++++++++++------ api/tangled/citriggerPipeline.go | 46 +++++++++++++++++++++++++++++++++++++++++----- api/tangled/pipelinecancelPipeline.go | 22 +++++++++++----------- api/tangled/tangledpipeline.go | 11 +++++++---- appview/knots/knots.go | 2 +- appview/oauth/oauth.go | 15 +++++++++++++++ appview/pages/funcmap.go | 57 +++++++++++++++++++++++++++++++++++++++++++++++++-------- appview/pages/pages.go | 6 ++++++ appview/pipelines/pipelines.go | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------- appview/pulls/compose.go | 2 +- appview/pulls/create.go | 2 +- appview/pulls/merge.go | 2 +- appview/pulls/resubmit.go | 2 +- appview/pulls/router.go | 5 +++++ appview/pulls/single.go | 15 ++++++++++++++- appview/pulls/trigger_ci.go | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/repo/artifact.go | 2 +- appview/repo/blob.go | 2 +- appview/repo/branches.go | 2 +- appview/repo/compare.go | 2 +- appview/repo/log.go | 2 +- appview/repo/repo.go | 2 +- appview/repo/settings.go | 2 +- appview/repo/tags.go | 2 +- appview/repo/tree.go | 2 +- appview/repoverify/verify.go | 159 --------------------------------------------------------------------------------------------------------------------------------------------------------------- appview/repoverify/verify_test.go | 66 ------------------------------------------------------------------ appview/serververify/verify.go | 2 +- appview/spindles/spindles.go | 2 +- appview/state/state.go | 4 ++-- appview/xrpcclient/xrpc.go | 39 --------------------------------------- cmd/cborgen/cborgen.go | 1 + lexicons/ci/pipeline.json | 5 +++++ lexicons/ci/trigger.json | 51 +++++++++++++++++++++++++++++++++++++++++++++++---- lexicons/ci/triggerPipeline.json | 24 +++++++++++------------- lexicons/pipeline/pipeline.json | 14 ++++++++++---- spindle/db/pipelines.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++----------------- spindle/engine/engine.go | 13 ++++++------- spindle/models/clone.go | 4 +--- spindle/models/clone_test.go | 36 +++++++++++++++++++++++++++++++++++- spindle/models/pipeline.go | 2 ++ spindle/models/pipeline_env.go | 37 ++++++++++++++++++++++++++----------- spindle/models/pipeline_env_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++++--- spindle/xrpc/ci_pipeline_trigger_pipeline.go | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------ spindle/xrpc/pipeline_cancel_pipeline.go | 57 ++++++++++++++++++++++++++++++++++++--------------------- spindle/xrpc/xrpc.go | 17 ++++++++++------- xrpc/xrpcclient/xrpc.go | 39 +++++++++++++++++++++++++++++++++++++++ appview/pages/templates/repo/pipelines/pipelines.html | 9 +++++++-- appview/pages/templates/repo/pipelines/workflow.html | 3 +-- appview/pages/templates/repo/pulls/pull.html | 10 +++++----- appview/pages/templates/repo/pulls/fragments/triggerCi.html | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 62 file(s) changed, 1908 insertion(s)(+), 581 deletion(s)(-) diff --git a/flake.nix b/flake.nix --- a/flake.nix +++ b/flake.nix @@ -519,7 +519,7 @@ find api/tangled -maxdepth 1 -name '*.go' -not -name '*_ext.go' -exec \ sed -i.bak 's/\tutil/\/\/\tutil/' {} + # lexgen generates incomplete Marshaler/Unmarshaler for union types - find api/tangled/*.go -not -name "cbor_gen.go" -exec \ + find api/tangled -maxdepth 1 -name '*.go' -not -name '*_ext.go' -not -name "cbor_gen.go" -exec \ sed -i '/^func.*\(MarshalCBOR\|UnmarshalCBOR\)/,/^}/ s/^/\/\/ /' {} + for f in api/tangled/*_ext.go; do [ -e "''$f" ] && mv "''$f" "''$f.bak"; done ${pkgs.gotools}/bin/goimports -w api/tangled/* diff --git a/appview/ingester.go b/appview/ingester.go --- a/appview/ingester.go +++ b/appview/ingester.go @@ -31,12 +31,12 @@ "tangled.org/core/appview/mentions" "tangled.org/core/appview/models" "tangled.org/core/appview/notify" - "tangled.org/core/appview/repoverify" "tangled.org/core/appview/serververify" "tangled.org/core/consts" "tangled.org/core/idresolver" "tangled.org/core/orm" "tangled.org/core/rbac" + "tangled.org/core/repoverify" ) type RepoPermissionChecker interface { diff --git a/appview/ingester_repo.go b/appview/ingester_repo.go --- a/appview/ingester_repo.go +++ b/appview/ingester_repo.go @@ -15,8 +15,8 @@ "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" - "tangled.org/core/appview/repoverify" "tangled.org/core/orm" + "tangled.org/core/repoverify" ) func (i *Ingester) ingestRepo(ctx context.Context, e *jmodels.Event, l *slog.Logger) error { diff --git a/appview/ingester_repo_test.go b/appview/ingester_repo_test.go --- a/appview/ingester_repo_test.go +++ b/appview/ingester_repo_test.go @@ -16,9 +16,9 @@ "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/notify" - "tangled.org/core/appview/repoverify" "tangled.org/core/orm" "tangled.org/core/rbac" + "tangled.org/core/repoverify" ) func mustKnotURL(t *testing.T, raw string) *url.URL { diff --git a/repoverify/verify.go b/repoverify/verify.go new file mode 100644 --- /dev/null +++ b/repoverify/verify.go @@ -0,0 +1,163 @@ +package repoverify + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "syscall" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "tangled.org/core/api/tangled" + "tangled.org/core/idresolver" + "tangled.org/core/xrpc/xrpcclient" +) + +type RepoDid syntax.DID + +func (r RepoDid) String() string { return string(r) } + +func NewRepoDid(s string) (RepoDid, error) { + did, err := syntax.ParseDID(s) + if err != nil { + return "", fmt.Errorf("invalid repoDid %q: %w", s, err) + } + return RepoDid(did), nil +} + +type OwnerDid syntax.DID + +func (o OwnerDid) String() string { return string(o) } + +func NewOwnerDid(s string) (OwnerDid, error) { + did, err := syntax.ParseDID(s) + if err != nil { + return "", fmt.Errorf("invalid ownerDid %q: %w", s, err) + } + return OwnerDid(did), nil +} + +func ParseKnotEndpoint(raw string, dev bool) (*url.URL, error) { + if raw == "" { + return nil, fmt.Errorf("empty knot URL") + } + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid knot URL %q: %w", raw, err) + } + if u.Host == "" { + return nil, fmt.Errorf("knot URL %q has no host", raw) + } + switch u.Scheme { + case "https": + case "http": + if !dev { + return nil, fmt.Errorf("knot URL %q must use https outside dev mode", raw) + } + default: + return nil, fmt.Errorf("knot URL %q has unsupported scheme %q", raw, u.Scheme) + } + return u, nil +} + +type Result struct { + RepoDid RepoDid + OwnerDid OwnerDid + KnotURL *url.URL + // Rkey of the sh.tangled.repo record tracked by the knot; empty when the + // knot does not support describeRepo. + Rkey string +} + +type Verifier func(ctx context.Context, repoDid RepoDid) (Result, error) + +const verifyTimeout = 10 * time.Second + +func New(resolver *idresolver.Resolver, dev bool) Verifier { + transport := &http.Transport{ + DialContext: safeDialer(dev).DialContext, + } + httpClient := &http.Client{ + Timeout: verifyTimeout, + Transport: transport, + } + + return func(ctx context.Context, repoDid RepoDid) (Result, error) { + ctx, cancel := context.WithTimeout(ctx, verifyTimeout) + defer cancel() + return resolveAndDescribe(ctx, resolver, httpClient, repoDid, dev) + } +} + +func resolveAndDescribe( + ctx context.Context, + resolver *idresolver.Resolver, + httpClient *http.Client, + repoDid RepoDid, + dev bool, +) (Result, error) { + ident, err := resolver.ResolveIdent(ctx, repoDid.String()) + if err != nil { + return Result{}, fmt.Errorf("resolve repoDid %s: %w", repoDid, err) + } + + knot, err := ParseKnotEndpoint(ident.GetServiceEndpoint("atproto_pds"), dev) + if err != nil { + return Result{}, fmt.Errorf("repoDid %s: %w", repoDid, err) + } + + client := &indigoxrpc.Client{Host: knot.String(), Client: httpClient} + out, err := tangled.RepoDescribeRepo(ctx, client, repoDid.String()) + if xrpcErr := xrpcclient.HandleXrpcErr(err); xrpcErr != nil { + if errors.Is(xrpcErr, xrpcclient.ErrXrpcUnsupported) { + return Result{RepoDid: repoDid, KnotURL: knot}, nil + } + return Result{}, fmt.Errorf("describeRepo on %s: %w", knot, xrpcErr) + } + + if out.RepoDid != repoDid.String() { + return Result{}, fmt.Errorf("knot %s returned mismatched repoDid: got %q, want %q", knot, out.RepoDid, repoDid) + } + + ownerDid, err := NewOwnerDid(out.OwnerDid) + if err != nil { + return Result{}, fmt.Errorf("describeRepo on %s returned invalid ownerDid: %w", knot, err) + } + + return Result{ + RepoDid: repoDid, + OwnerDid: ownerDid, + KnotURL: knot, + Rkey: out.Rkey, + }, nil +} + +func safeDialer(dev bool) *net.Dialer { + d := &net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + } + if dev { + return d + } + d.Control = func(network, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("invalid dial address %q: %w", address, err) + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("dial address %q did not resolve to IP", address) + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { + return fmt.Errorf("refusing to dial %s: reserved or private address", ip) + } + return nil + } + return d +} diff --git a/repoverify/verify_test.go b/repoverify/verify_test.go new file mode 100644 --- /dev/null +++ b/repoverify/verify_test.go @@ -0,0 +1,66 @@ +package repoverify + +import "testing" + +func TestNewRepoDid_RejectsInvalid(t *testing.T) { + if _, err := NewRepoDid(""); err == nil { + t.Error("NewRepoDid(\"\") err = nil, want error") + } +} + +func TestNewRepoDid_AcceptsValid(t *testing.T) { + raw := "did:plc:abc123abc123abc123abc123" + got, err := NewRepoDid(raw) + if err != nil { + t.Fatalf("NewRepoDid(%q): %v", raw, err) + } + if got.String() != raw { + t.Errorf("got %q, want %q", got, raw) + } +} + +func TestParseKnotEndpoint_RejectsHttpInProd(t *testing.T) { + if _, err := ParseKnotEndpoint("http://knot.example", false); err == nil { + t.Error("http:// knot URL accepted in prod") + } +} + +func TestParseKnotEndpoint_AllowsHttpInDev(t *testing.T) { + u, err := ParseKnotEndpoint("http://knot.example", true) + if err != nil { + t.Fatalf("dev mode should allow http: %v", err) + } + if u.Host != "knot.example" { + t.Errorf("Host = %q, want knot.example", u.Host) + } +} + +func TestParseKnotEndpoint_RejectsUnsupportedScheme(t *testing.T) { + if _, err := ParseKnotEndpoint("ftp://knot.example", true); err == nil { + t.Error("ParseKnotEndpoint accepted ftp:// in dev") + } + if _, err := ParseKnotEndpoint("ftp://knot.example", false); err == nil { + t.Error("ParseKnotEndpoint accepted ftp:// in prod") + } +} + +func TestParseKnotEndpoint_RejectsEmptyOrHostless(t *testing.T) { + cases := []string{"", "https://", "not a url at all"} + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + if _, err := ParseKnotEndpoint(raw, false); err == nil { + t.Errorf("ParseKnotEndpoint(%q) accepted bogus URL", raw) + } + }) + } +} + +func TestParseKnotEndpoint_HostPreservesPort(t *testing.T) { + u, err := ParseKnotEndpoint("http://localhost:3000", true) + if err != nil { + t.Fatalf("ParseKnotEndpoint: %v", err) + } + if u.Host != "localhost:3000" { + t.Errorf("Host = %q, want localhost:3000", u.Host) + } +} diff --git a/spindle/server.go b/spindle/server.go --- a/spindle/server.go +++ b/spindle/server.go @@ -28,6 +28,7 @@ "tangled.org/core/log" "tangled.org/core/notifier" "tangled.org/core/rbac" + "tangled.org/core/repoverify" "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" "tangled.org/core/spindle/engine" @@ -64,6 +65,7 @@ cfg *config.Config ks *eventconsumer.Consumer res *idresolver.Resolver + verify repoverify.Verifier vault secrets.Manager motd []byte motdMu sync.RWMutex @@ -156,6 +158,7 @@ jq: jq, cfg: cfg, res: resolver, + verify: repoverify.New(resolver, cfg.Server.Dev), vault: vault, motd: defaultMotd, rootCtx: ctx, @@ -447,7 +450,7 @@ Repo: triggerRepo, } - pipelineId, err := s.runPipeline(ctx, repoDid, trigger, event.ChangedFiles, repoCloneUri, repoPath, event.NewSha, nil) + pipelineId, err := s.runPipeline(ctx, repoDid, trigger, event.ChangedFiles, repoCloneUri, repoPath, event.NewSha, nil, triggerRepo) if err != nil { return err } @@ -463,31 +466,70 @@ // buildTriggerRepo gathers trigger metadata, resolving default branch from the knot func (s *Spindle) buildTriggerRepo(ctx context.Context, repo *db.Repo) (*tangled.Pipeline_TriggerRepo, error) { + rkey := string(repo.Rkey) + repoDid := repo.RepoDid.String() + return s.buildTriggerRepoFrom(ctx, repo.Knot, repo.Owner.String(), rkey, repoDid), nil +} + +func (s *Spindle) buildTriggerRepoFrom(ctx context.Context, knot, did, rkey, repoDid string) *tangled.Pipeline_TriggerRepo { scheme := "https" if s.cfg.Server.Dev { scheme = "http" } - client := &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, repo.Knot)} + client := &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, knot)} - // todo(dawn): this should be in the refUpdate event itself to save a roundtrip + // this should maybe (?) be in the refUpdate event itself to save a roundtrip defaultBranch := "" - if out, err := tangled.RepoGetDefaultBranch(ctx, client, repo.RepoDid.String()); err == nil { + if out, err := tangled.RepoGetDefaultBranch(ctx, client, repoDid); err == nil { defaultBranch = out.Name } - rkey := string(repo.Rkey) - repoDid := repo.RepoDid.String() + var rkeyPtr *string + if rkey != "" { + rkeyPtr = &rkey + } return &tangled.Pipeline_TriggerRepo{ - Did: repo.Owner.String(), - Knot: repo.Knot, - Repo: &rkey, + Did: did, + Knot: knot, + Repo: rkeyPtr, RepoDid: &repoDid, DefaultBranch: defaultBranch, - }, nil + } } -// runPipeline compiles and enqueues the pipeline for the given revision -func (s *Spindle) runPipeline(ctx context.Context, repoDid syntax.DID, trigger tangled.Pipeline_TriggerMetadata, changedFiles []string, repoCloneUri, repoPath, rev string, only []string) (models.PipelineId, error) { +func (s *Spindle) resolvePipelineSourceRepo(ctx context.Context, trigger *tangled.Pipeline_TriggerMetadata) (*tangled.Pipeline_TriggerRepo, error) { + if trigger == nil { + return nil, nil + } + if trigger.SourceRepo == nil || *trigger.SourceRepo == "" { + return trigger.Repo, nil + } + repoDid, err := syntax.ParseDID(*trigger.SourceRepo) + if err != nil { + return nil, fmt.Errorf("parse sourceRepo %s: %w", *trigger.SourceRepo, err) + } + return s.resolveSourceRepoInfo(ctx, repoDid) +} + +// resolveSourceRepoInfo resolves trigger-repo metadata for a source repo DID. +func (s *Spindle) resolveSourceRepoInfo(ctx context.Context, repoDid syntax.DID) (*tangled.Pipeline_TriggerRepo, error) { + repo, err := s.db.GetRepoByDid(repoDid) + if err == nil { + return s.buildTriggerRepo(ctx, repo) + } + + // verify repo, we don't want git sync to point to arbitrary endpoints + res, err := s.verify(ctx, repoverify.RepoDid(repoDid)) + if err != nil { + return nil, fmt.Errorf("verify sourceRepo %s: %w", repoDid, err) + } + return s.buildTriggerRepoFrom(ctx, res.KnotURL.Host, res.OwnerDid.String(), res.Rkey, repoDid.String()), nil +} + +// runPipeline compiles and enqueues the pipeline for the given revision. +// sourceRepo is the resolved repo the code was checked out from, forwarded to +// processPipeline for env vars. +func (s *Spindle) runPipeline(ctx context.Context, repoDid syntax.DID, trigger tangled.Pipeline_TriggerMetadata, changedFiles []string, repoCloneUri, repoPath, rev string, only []string, sourceRepo *tangled.Pipeline_TriggerRepo) (models.PipelineId, error) { l := log.FromContext(ctx) compiler := workflow.Compiler{ @@ -526,7 +568,7 @@ if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { return models.PipelineId{}, fmt.Errorf("creating pipeline event: %w", err) } - err = s.processPipeline(repoDid, tpl, pipelineId) + err = s.processPipeline(repoDid, tpl, pipelineId, sourceRepo) return pipelineId, err } @@ -548,8 +590,9 @@ return filtered } -// TriggerManual dispatches a pipeline manually at sha -func (s *Spindle) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string) (syntax.ATURI, error) { +// TriggerManual dispatches a pipeline at sha, authorized against and recorded +// under repoDid. sourceRepo, pull, and inputs are optional trigger payload. +func (s *Spindle) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull xrpc.PullContext, inputs []*tangled.Pipeline_Pair) (syntax.ATURI, error) { repo, err := s.db.GetRepoByDid(repoDid) if err != nil { return "", fmt.Errorf("unknown repoDid %s: %w", repoDid, err) @@ -560,23 +603,48 @@ return "", fmt.Errorf("building trigger repo: %w", err) } - var refPtr *string - if ref != "" { - refPtr = &ref - } - trigger := tangled.Pipeline_TriggerMetadata{ - Kind: string(workflow.TriggerKindManual), - Manual: &tangled.Pipeline_ManualTriggerData{ - Sha: sha, - Ref: refPtr, - }, - Repo: triggerRepo, + trigger := tangled.Pipeline_TriggerMetadata{Repo: triggerRepo} + if pull.IsPullRequest { + var pullAt *string + if pull.Pull != "" { + pullAtStr := pull.Pull.String() + pullAt = &pullAtStr + } + trigger.Kind = string(workflow.TriggerKindPullRequest) + trigger.PullRequest = &tangled.Pipeline_PullRequestTriggerData{ + SourceBranch: pull.SourceBranch, + TargetBranch: pull.TargetBranch, + SourceSha: sha, + Pull: pullAt, + } + } else { + var refPtr *string + if ref != "" { + refPtr = &ref + } + trigger.Kind = string(workflow.TriggerKindManual) + trigger.Manual = &tangled.Pipeline_ManualTriggerData{ + Sha: sha, + Ref: refPtr, + Inputs: inputs, + } } repoCloneUri := s.newRepoCloneUrl(repo.Knot, repoDid) repoPath := s.newRepoPath(repoDid) + sourceInfo := triggerRepo // default: code comes from the repo itself + if sourceRepo != "" && sourceRepo != repoDid { + sourceInfo, err = s.resolveSourceRepoInfo(ctx, sourceRepo) + if err != nil { + return "", err + } + sourceRepoStr := sourceRepo.String() + trigger.SourceRepo = &sourceRepoStr + repoCloneUri = models.BuildRepoURL(sourceInfo) + repoPath = s.newRepoPath(sourceRepo) + } - pipelineId, err := s.runPipeline(ctx, repoDid, trigger, nil, repoCloneUri, repoPath, sha, workflows) + pipelineId, err := s.runPipeline(ctx, repoDid, trigger, nil, repoCloneUri, repoPath, sha, workflows, sourceInfo) if err != nil { return "", err } @@ -624,9 +692,26 @@ return rawPipeline, nil } -func (s *Spindle) processPipeline(repoDid syntax.DID, tpl tangled.Pipeline, pipelineId models.PipelineId) error { - // Build pipeline environment variables once for all workflows - pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId) +// processPipeline enqueues the workflows in tpl. +func (s *Spindle) processPipeline(repoDid syntax.DID, tpl tangled.Pipeline, pipelineId models.PipelineId, sourceRepo *tangled.Pipeline_TriggerRepo) error { + // derive security-relevant things like whether this run is trusted and can be passed + // secrets to from the original metadata. + pipelineEnv := models.PipelineEnvVarsForSource(tpl.TriggerMetadata, pipelineId, sourceRepo) + trustedSource := true + if tm := tpl.TriggerMetadata; tm != nil && tm.SourceRepo != nil && + *tm.SourceRepo != "" && *tm.SourceRepo != repoDid.String() { + trustedSource = false + } + + // swap the repo with our sourceRepo if we are running a pipeline on a fork. + // the metadata stays the same. we check whether the repo is trusted above, + // so this only affects the clone URL. + initTpl := tpl + if sourceRepo != nil && tpl.TriggerMetadata != nil { + tm := *tpl.TriggerMetadata + tm.Repo = sourceRepo + initTpl.TriggerMetadata = &tm + } // filter & init workflows workflows := make(map[models.Engine][]models.Workflow) @@ -647,7 +732,7 @@ continue } - ewf, err := eng.InitWorkflow(*w, tpl) + ewf, err := eng.InitWorkflow(*w, initTpl) if err != nil { err = s.db.StatusFailed(models.WorkflowId{ PipelineId: pipelineId, @@ -674,8 +759,9 @@ ok := s.jq.Enqueue(repoDid, queue.Job{ Run: func() error { engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.rootCtx, &models.Pipeline{ - RepoDid: repoDid, - Workflows: workflows, + RepoDid: repoDid, + Workflows: workflows, + TrustedSource: trustedSource, }, pipelineId) return nil }, diff --git a/spindle/tapclient.go b/spindle/tapclient.go --- a/spindle/tapclient.go +++ b/spindle/tapclient.go @@ -383,7 +383,6 @@ Trigger: tangled.Pipeline_TriggerMetadata{ Kind: string(workflow.TriggerKindPullRequest), PullRequest: &tangled.Pipeline_PullRequestTriggerData{ - Action: "create", SourceBranch: record.Source.Branch, SourceSha: sourceSha, TargetBranch: record.Target.Branch, @@ -433,7 +432,12 @@ l.Error("failed to create pipeline event", "err", err) return nil } - err = t.spindle.processPipeline(repo.RepoDid, tpl, pipelineId) + sourceRepo, err := t.spindle.resolvePipelineSourceRepo(ctx, tpl.TriggerMetadata) + if err != nil { + l.Error("failed resolving pipeline source repo", "err", err) + return nil + } + err = t.spindle.processPipeline(repo.RepoDid, tpl, pipelineId, sourceRepo) if err != nil { // don't retry l.Error("failed processing pipeline", "err", err) diff --git a/types/pipeline.go b/types/pipeline.go --- a/types/pipeline.go +++ b/types/pipeline.go @@ -106,11 +106,11 @@ } if t.CiPipeline_Trigger.CiTrigger_Push != nil { ref := t.CiPipeline_Trigger.CiTrigger_Push.Ref - if strings.HasPrefix(ref, "refs/heads/") { - return strings.TrimPrefix(ref, "refs/heads/") + if after, ok := strings.CutPrefix(ref, "refs/heads/"); ok { + return after } - if strings.HasPrefix(ref, "refs/tags/") { - return strings.TrimPrefix(ref, "refs/tags/") + if after, ok := strings.CutPrefix(ref, "refs/tags/"); ok { + return after } return ref } @@ -129,6 +129,17 @@ return "" } return *sb +} + +func (t Trigger) PRUri() string { + if t.CiPipeline_Trigger == nil || t.CiPipeline_Trigger.CiTrigger_PullRequest == nil { + return "" + } + pull := t.CiPipeline_Trigger.CiTrigger_PullRequest.Pull + if pull == nil { + return "" + } + return *pull } type Pipeline struct { @@ -240,6 +251,14 @@ return "" } return p.CiPipeline.Commit +} + +// where the pipeline commit was checked out from, nil when checked out from repo itself +func (p Pipeline) SourceRepo() *string { + if p.CiPipeline == nil { + return nil + } + return p.CiPipeline.SourceRepo } func (p Pipeline) Workflows() []string { diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -669,13 +669,17 @@ } cw := cbg.NewCborWriter(w) - fieldCount := 6 + fieldCount := 7 if t.CreatedAt == nil { fieldCount-- } if t.Repo == nil { + fieldCount-- + } + + if t.SourceRepo == nil { fieldCount-- } @@ -834,6 +838,38 @@ } } + + // t.SourceRepo (string) (string) + if t.SourceRepo != nil { + + if len("sourceRepo") > 1000000 { + return xerrors.Errorf("Value in field \"sourceRepo\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sourceRepo"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sourceRepo")); err != nil { + return err + } + + if t.SourceRepo == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.SourceRepo) > 1000000 { + return xerrors.Errorf("Value in field t.SourceRepo was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.SourceRepo))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.SourceRepo)); err != nil { + return err + } + } + } return nil } @@ -862,7 +898,7 @@ n := extra - nameBuf := make([]byte, 9) + nameBuf := make([]byte, 10) for i := uint64(0); i < n; i++ { nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) if err != nil { @@ -1009,6 +1045,27 @@ } + } + } + // t.SourceRepo (string) (string) + case "sourceRepo": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.SourceRepo = (*string)(&sval) } } @@ -2181,8 +2238,76 @@ } cw := cbg.NewCborWriter(w) + fieldCount := 5 - if _, err := cw.Write([]byte{161}); err != nil { + if t.Inputs == nil { + fieldCount-- + } + + if t.Ref == nil { + fieldCount-- + } + + if t.SourceRepo == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Ref (string) (string) + if t.Ref != nil { + + if len("ref") > 1000000 { + return xerrors.Errorf("Value in field \"ref\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("ref"))); err != nil { + return err + } + if _, err := cw.WriteString(string("ref")); err != nil { + return err + } + + if t.Ref == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Ref) > 1000000 { + return xerrors.Errorf("Value in field t.Ref was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Ref))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Ref)); err != nil { + return err + } + } + } + + // t.Sha (string) (string) + if len("sha") > 1000000 { + return xerrors.Errorf("Value in field \"sha\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sha"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sha")); err != nil { + return err + } + + if len(t.Sha) > 1000000 { + return xerrors.Errorf("Value in field t.Sha was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Sha))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Sha)); err != nil { return err } @@ -2203,6 +2328,67 @@ } if _, err := cw.WriteString(string("sh.tangled.ci.trigger#manual")); err != nil { return err + } + + // t.Inputs ([]*tangled.CiTrigger_Pair) (slice) + if t.Inputs != nil { + + if len("inputs") > 1000000 { + return xerrors.Errorf("Value in field \"inputs\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil { + return err + } + if _, err := cw.WriteString(string("inputs")); err != nil { + return err + } + + if len(t.Inputs) > 8192 { + return xerrors.Errorf("Slice value in field t.Inputs was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil { + return err + } + for _, v := range t.Inputs { + if err := v.MarshalCBOR(cw); err != nil { + return err + } + + } + } + + // t.SourceRepo (string) (string) + if t.SourceRepo != nil { + + if len("sourceRepo") > 1000000 { + return xerrors.Errorf("Value in field \"sourceRepo\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sourceRepo"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sourceRepo")); err != nil { + return err + } + + if t.SourceRepo == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.SourceRepo) > 1000000 { + return xerrors.Errorf("Value in field t.SourceRepo was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.SourceRepo))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.SourceRepo)); err != nil { + return err + } + } } return nil } @@ -2232,6 +2418,231 @@ n := extra + nameBuf := make([]byte, 10) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Ref (string) (string) + case "ref": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Ref = (*string)(&sval) + } + } + // t.Sha (string) (string) + case "sha": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Sha = string(sval) + } + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.Inputs ([]*tangled.CiTrigger_Pair) (slice) + case "inputs": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Inputs: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Inputs = make([]*CiTrigger_Pair, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Inputs[i] = new(CiTrigger_Pair) + if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) + } + } + + } + + } + } + // t.SourceRepo (string) (string) + case "sourceRepo": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.SourceRepo = (*string)(&sval) + } + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *CiTrigger_Pair) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{162}); err != nil { + return err + } + + // t.Key (string) (string) + if len("key") > 1000000 { + return xerrors.Errorf("Value in field \"key\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("key"))); err != nil { + return err + } + if _, err := cw.WriteString(string("key")); err != nil { + return err + } + + if len(t.Key) > 1000000 { + return xerrors.Errorf("Value in field t.Key was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Key))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Key)); err != nil { + return err + } + + // t.Value (string) (string) + if len("value") > 1000000 { + return xerrors.Errorf("Value in field \"value\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("value"))); err != nil { + return err + } + if _, err := cw.WriteString(string("value")); err != nil { + return err + } + + if len(t.Value) > 1000000 { + return xerrors.Errorf("Value in field t.Value was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Value))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Value)); err != nil { + return err + } + return nil +} + +func (t *CiTrigger_Pair) UnmarshalCBOR(r io.Reader) (err error) { + *t = CiTrigger_Pair{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("CiTrigger_Pair: map struct too large (%d)", extra) + } + + n := extra + nameBuf := make([]byte, 5) for i := uint64(0); i < n; i++ { nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) @@ -2248,8 +2659,8 @@ } switch string(nameBuf[:nameLen]) { - // t.LexiconTypeID (string) (string) - case "$type": + // t.Key (string) (string) + case "key": { sval, err := cbg.ReadStringWithMax(cr, 1000000) @@ -2257,7 +2668,18 @@ return err } - t.LexiconTypeID = string(sval) + t.Key = string(sval) + } + // t.Value (string) (string) + case "value": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Value = string(sval) } default: @@ -2277,14 +2699,54 @@ } cw := cbg.NewCborWriter(w) - fieldCount := 5 + fieldCount := 6 + + if t.Pull == nil { + fieldCount-- + } if t.SourceBranch == nil { fieldCount-- } + if t.SourceRepo == nil { + fieldCount-- + } + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { return err + } + + // t.Pull (string) (string) + if t.Pull != nil { + + if len("pull") > 1000000 { + return xerrors.Errorf("Value in field \"pull\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pull"))); err != nil { + return err + } + if _, err := cw.WriteString(string("pull")); err != nil { + return err + } + + if t.Pull == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Pull) > 1000000 { + return xerrors.Errorf("Value in field t.Pull was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Pull))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Pull)); err != nil { + return err + } + } } // t.LexiconTypeID (string) (string) @@ -2303,29 +2765,6 @@ return err } if _, err := cw.WriteString(string("sh.tangled.ci.trigger#pullRequest")); err != nil { - return err - } - - // t.Action (string) (string) - if len("action") > 1000000 { - return xerrors.Errorf("Value in field \"action\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("action"))); err != nil { - return err - } - if _, err := cw.WriteString(string("action")); err != nil { - return err - } - - if len(t.Action) > 1000000 { - return xerrors.Errorf("Value in field t.Action was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Action))); err != nil { - return err - } - if _, err := cw.WriteString(string(t.Action)); err != nil { return err } @@ -2350,6 +2789,38 @@ } if _, err := cw.WriteString(string(t.SourceSha)); err != nil { return err + } + + // t.SourceRepo (string) (string) + if t.SourceRepo != nil { + + if len("sourceRepo") > 1000000 { + return xerrors.Errorf("Value in field \"sourceRepo\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sourceRepo"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sourceRepo")); err != nil { + return err + } + + if t.SourceRepo == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.SourceRepo) > 1000000 { + return xerrors.Errorf("Value in field t.SourceRepo was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.SourceRepo))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.SourceRepo)); err != nil { + return err + } + } } // t.SourceBranch (string) (string) @@ -2450,7 +2921,28 @@ } switch string(nameBuf[:nameLen]) { - // t.LexiconTypeID (string) (string) + // t.Pull (string) (string) + case "pull": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Pull = (*string)(&sval) + } + } + // t.LexiconTypeID (string) (string) case "$type": { @@ -2460,17 +2952,6 @@ } t.LexiconTypeID = string(sval) - } - // t.Action (string) (string) - case "action": - - { - sval, err := cbg.ReadStringWithMax(cr, 1000000) - if err != nil { - return err - } - - t.Action = string(sval) } // t.SourceSha (string) (string) case "sourceSha": @@ -2482,6 +2963,27 @@ } t.SourceSha = string(sval) + } + // t.SourceRepo (string) (string) + case "sourceRepo": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.SourceRepo = (*string)(&sval) + } } // t.SourceBranch (string) (string) case "sourceBranch": @@ -7885,32 +8387,46 @@ } cw := cbg.NewCborWriter(w) + fieldCount := 4 - if _, err := cw.Write([]byte{164}); err != nil { + if t.Pull == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { return err } - // t.Action (string) (string) - if len("action") > 1000000 { - return xerrors.Errorf("Value in field \"action\" was too long") - } + // t.Pull (string) (string) + if t.Pull != nil { - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("action"))); err != nil { - return err - } - if _, err := cw.WriteString(string("action")); err != nil { - return err - } + if len("pull") > 1000000 { + return xerrors.Errorf("Value in field \"pull\" was too long") + } - if len(t.Action) > 1000000 { - return xerrors.Errorf("Value in field t.Action was too long") - } + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pull"))); err != nil { + return err + } + if _, err := cw.WriteString(string("pull")); err != nil { + return err + } - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Action))); err != nil { - return err - } - if _, err := cw.WriteString(string(t.Action)); err != nil { - return err + if t.Pull == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Pull) > 1000000 { + return xerrors.Errorf("Value in field t.Pull was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Pull))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Pull)); err != nil { + return err + } + } } // t.SourceSha (string) (string) @@ -8025,16 +8541,26 @@ } switch string(nameBuf[:nameLen]) { - // t.Action (string) (string) - case "action": + // t.Pull (string) (string) + case "pull": { - sval, err := cbg.ReadStringWithMax(cr, 1000000) + b, err := cr.ReadByte() if err != nil { return err } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } - t.Action = string(sval) + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Pull = (*string)(&sval) + } } // t.SourceSha (string) (string) case "sourceSha": @@ -8617,7 +9143,7 @@ } cw := cbg.NewCborWriter(w) - fieldCount := 5 + fieldCount := 6 if t.Manual == nil { fieldCount-- @@ -8628,6 +9154,10 @@ } if t.Push == nil { + fieldCount-- + } + + if t.SourceRepo == nil { fieldCount-- } @@ -8709,6 +9239,38 @@ if err := t.Manual.MarshalCBOR(cw); err != nil { return err + } + } + + // t.SourceRepo (string) (string) + if t.SourceRepo != nil { + + if len("sourceRepo") > 1000000 { + return xerrors.Errorf("Value in field \"sourceRepo\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sourceRepo"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sourceRepo")); err != nil { + return err + } + + if t.SourceRepo == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.SourceRepo) > 1000000 { + return xerrors.Errorf("Value in field t.SourceRepo was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.SourceRepo))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.SourceRepo)); err != nil { + return err + } } } @@ -8844,6 +9406,27 @@ } } + } + // t.SourceRepo (string) (string) + case "sourceRepo": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.SourceRepo = (*string)(&sval) + } } // t.PullRequest (tangled.Pipeline_PullRequestTriggerData) (struct) case "pullRequest": diff --git a/api/tangled/cipipeline.go b/api/tangled/cipipeline.go --- a/api/tangled/cipipeline.go +++ b/api/tangled/cipipeline.go @@ -26,6 +26,8 @@ Id string `json:"id" cborgen:"id"` // repo: Repository DID Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` + // sourceRepo: Repository DID that the commit was checked out from, if different from repo (e.g. a fork for a fork-based pull request) + SourceRepo *string `json:"sourceRepo,omitempty" cborgen:"sourceRepo,omitempty"` // trigger: Trigger event metadata Trigger *CiPipeline_Trigger `json:"trigger" cborgen:"trigger"` // workflows: Triggered workflows diff --git a/api/tangled/citrigger.go b/api/tangled/citrigger.go --- a/api/tangled/citrigger.go +++ b/api/tangled/citrigger.go @@ -10,7 +10,20 @@ // // RECORDTYPE: CiTrigger_Manual type CiTrigger_Manual struct { - LexiconTypeID string `json:"$type,const=sh.tangled.ci.trigger#manual" cborgen:"$type,const=sh.tangled.ci.trigger#manual"` + LexiconTypeID string `json:"$type,const=sh.tangled.ci.trigger#manual" cborgen:"$type,const=sh.tangled.ci.trigger#manual"` + Inputs []*CiTrigger_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` + // ref: optional ref the SHA was resolved from, for display and TANGLED_REF + Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` + // sha: commit SHA the manual run targets + Sha string `json:"sha" cborgen:"sha"` + // sourceRepo: Repository DID to check out code and workflow definitions from, if different from the target repo. + SourceRepo *string `json:"sourceRepo,omitempty" cborgen:"sourceRepo,omitempty"` +} + +// CiTrigger_Pair is a "pair" in the sh.tangled.ci.trigger schema. +type CiTrigger_Pair struct { + Key string `json:"key" cborgen:"key"` + Value string `json:"value" cborgen:"value"` } // CiTrigger_PullRequest is a "pullRequest" in the sh.tangled.ci.trigger schema. @@ -19,11 +32,14 @@ // // RECORDTYPE: CiTrigger_PullRequest type CiTrigger_PullRequest struct { - LexiconTypeID string `json:"$type,const=sh.tangled.ci.trigger#pullRequest" cborgen:"$type,const=sh.tangled.ci.trigger#pullRequest"` - Action string `json:"action" cborgen:"action"` - SourceBranch *string `json:"sourceBranch,omitempty" cborgen:"sourceBranch,omitempty"` - SourceSha string `json:"sourceSha" cborgen:"sourceSha"` - TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` + LexiconTypeID string `json:"$type,const=sh.tangled.ci.trigger#pullRequest" cborgen:"$type,const=sh.tangled.ci.trigger#pullRequest"` + // pull: AT-URI of the sh.tangled.repo.pull record this run belongs to + Pull *string `json:"pull,omitempty" cborgen:"pull,omitempty"` + SourceBranch *string `json:"sourceBranch,omitempty" cborgen:"sourceBranch,omitempty"` + // sourceRepo: Repository DID to check out code and workflow definitions from, if different from the target repo. + SourceRepo *string `json:"sourceRepo,omitempty" cborgen:"sourceRepo,omitempty"` + SourceSha string `json:"sourceSha" cborgen:"sourceSha"` + TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` } // CiTrigger_Push is a "push" in the sh.tangled.ci.trigger schema. diff --git a/api/tangled/citriggerPipeline.go b/api/tangled/citriggerPipeline.go --- a/api/tangled/citriggerPipeline.go +++ b/api/tangled/citriggerPipeline.go @@ -6,6 +6,8 @@ import ( "context" + "encoding/json" + "fmt" "github.com/bluesky-social/indigo/lex/util" ) @@ -16,14 +18,48 @@ // CiTriggerPipeline_Input is the input argument to a sh.tangled.ci.triggerPipeline call. type CiTriggerPipeline_Input struct { - // ref: optional ref the SHA was resolved from, for display - Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` - // repo: AT-URI of the sh.tangled.repo record + // repo: Target repository DID. Auth is checked against this repo. Repo string `json:"repo" cborgen:"repo"` - // sha: commit SHA to run the pipeline at - Sha string `json:"sha" cborgen:"sha"` + // trigger: Trigger metadata for this dispatch. + Trigger *CiTriggerPipeline_Input_Trigger `json:"trigger" cborgen:"trigger"` // workflows: Workflow names to run. When not provided, every dispatchable workflow is run. Workflows []string `json:"workflows,omitempty" cborgen:"workflows,omitempty"` +} + +// Trigger metadata for this dispatch. +type CiTriggerPipeline_Input_Trigger struct { + CiTrigger_Manual *CiTrigger_Manual + CiTrigger_PullRequest *CiTrigger_PullRequest +} + +func (t *CiTriggerPipeline_Input_Trigger) MarshalJSON() ([]byte, error) { + if t.CiTrigger_Manual != nil { + t.CiTrigger_Manual.LexiconTypeID = "sh.tangled.ci.trigger#manual" + return json.Marshal(t.CiTrigger_Manual) + } + if t.CiTrigger_PullRequest != nil { + t.CiTrigger_PullRequest.LexiconTypeID = "sh.tangled.ci.trigger#pullRequest" + return json.Marshal(t.CiTrigger_PullRequest) + } + return nil, fmt.Errorf("cannot marshal empty enum") +} +func (t *CiTriggerPipeline_Input_Trigger) UnmarshalJSON(b []byte) error { + typ, err := util.TypeExtract(b) + if err != nil { + return err + } + + switch typ { + case "sh.tangled.ci.trigger#manual": + t.CiTrigger_Manual = new(CiTrigger_Manual) + return json.Unmarshal(b, t.CiTrigger_Manual) + case "sh.tangled.ci.trigger#pullRequest": + t.CiTrigger_PullRequest = new(CiTrigger_PullRequest) + return json.Unmarshal(b, t.CiTrigger_PullRequest) + + default: + return nil + } } // CiTriggerPipeline_Output is the output of a sh.tangled.ci.triggerPipeline call. diff --git a/api/tangled/pipelinecancelPipeline.go b/api/tangled/pipelinecancelPipeline.go --- a/api/tangled/pipelinecancelPipeline.go +++ b/api/tangled/pipelinecancelPipeline.go @@ -2,7 +2,7 @@ package tangled -// schema: sh.tangled.ci.pipeline.cancelPipeline +// schema: sh.tangled.pipeline.cancelPipeline import ( "context" @@ -11,22 +11,22 @@ ) const ( - CiPipelineCancelPipelineNSID = "sh.tangled.ci.pipeline.cancelPipeline" + PipelineCancelPipelineNSID = "sh.tangled.pipeline.cancelPipeline" ) -// CiPipelineCancelPipeline_Input is the input argument to a sh.tangled.ci.pipeline.cancelPipeline call. -type CiPipelineCancelPipeline_Input struct { - // pipeline: pipeline TID +// PipelineCancelPipeline_Input is the input argument to a sh.tangled.pipeline.cancelPipeline call. +type PipelineCancelPipeline_Input struct { + // pipeline: pipeline at-uri Pipeline string `json:"pipeline" cborgen:"pipeline"` - // repo: git repository DID + // repo: repo at-uri, spindle can't resolve repo from pipeline at-uri yet Repo string `json:"repo" cborgen:"repo"` - // workflows: Workflow names to filter. When not provided, entire pipeline will be canceled. - Workflows []string `json:"workflows,omitempty" cborgen:"workflows,omitempty"` + // workflow: workflow name + Workflow string `json:"workflow" cborgen:"workflow"` } -// CiPipelineCancelPipeline calls the XRPC method "sh.tangled.ci.pipeline.cancelPipeline". -func CiPipelineCancelPipeline(ctx context.Context, c util.LexClient, input *CiPipelineCancelPipeline_Input) error { - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.ci.pipeline.cancelPipeline", nil, input, nil); err != nil { +// PipelineCancelPipeline calls the XRPC method "sh.tangled.pipeline.cancelPipeline". +func PipelineCancelPipeline(ctx context.Context, c util.LexClient, input *PipelineCancelPipeline_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.pipeline.cancelPipeline", nil, input, nil); err != nil { return err } diff --git a/api/tangled/tangledpipeline.go b/api/tangled/tangledpipeline.go --- a/api/tangled/tangledpipeline.go +++ b/api/tangled/tangledpipeline.go @@ -49,10 +49,11 @@ // Pipeline_PullRequestTriggerData is a "pullRequestTriggerData" in the sh.tangled.pipeline schema. type Pipeline_PullRequestTriggerData struct { - Action string `json:"action" cborgen:"action"` - SourceBranch string `json:"sourceBranch" cborgen:"sourceBranch"` - SourceSha string `json:"sourceSha" cborgen:"sourceSha"` - TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` + // pull: AT-URI of the sh.tangled.repo.pull record this run belongs to + Pull *string `json:"pull,omitempty" cborgen:"pull,omitempty"` + SourceBranch string `json:"sourceBranch" cborgen:"sourceBranch"` + SourceSha string `json:"sourceSha" cborgen:"sourceSha"` + TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` } // Pipeline_PushTriggerData is a "pushTriggerData" in the sh.tangled.pipeline schema. @@ -69,6 +70,8 @@ PullRequest *Pipeline_PullRequestTriggerData `json:"pullRequest,omitempty" cborgen:"pullRequest,omitempty"` Push *Pipeline_PushTriggerData `json:"push,omitempty" cborgen:"push,omitempty"` Repo *Pipeline_TriggerRepo `json:"repo" cborgen:"repo"` + // sourceRepo: Repository DID that code and workflow definitions are checked out from, when different from repo (e.g. a fork's commit for a fork-based manual trigger). If absent, source uses repo itself. + SourceRepo *string `json:"sourceRepo,omitempty" cborgen:"sourceRepo,omitempty"` } // Pipeline_TriggerRepo is a "triggerRepo" in the sh.tangled.pipeline schema. diff --git a/appview/knots/knots.go b/appview/knots/knots.go --- a/appview/knots/knots.go +++ b/appview/knots/knots.go @@ -20,13 +20,13 @@ "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/serververify" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/consts" "tangled.org/core/eventconsumer" "tangled.org/core/idresolver" "tangled.org/core/orm" "tangled.org/core/rbac" "tangled.org/core/tid" + "tangled.org/core/xrpc/xrpcclient" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atclient" diff --git a/appview/oauth/oauth.go b/appview/oauth/oauth.go --- a/appview/oauth/oauth.go +++ b/appview/oauth/oauth.go @@ -22,6 +22,7 @@ "golang.org/x/sync/singleflight" "tangled.org/core/appview/config" "tangled.org/core/appview/db" + "tangled.org/core/hostutil" "tangled.org/core/idresolver" "tangled.org/core/rbac" "tangled.org/core/xrpc/serviceauth" @@ -443,6 +444,20 @@ Timeout: opts.timeout, }, }, nil +} + +func (o *OAuth) SpindleServiceClient(r *http.Request, spindle, lxm string) (*xrpc.Client, error) { + hostname, noTLS, err := hostutil.ParseHostname(spindle) + if err != nil { + return nil, err + } + return o.ServiceClient( + r, + WithService(hostname), + WithLxm(lxm), + WithDev(noTLS), + WithTimeout(time.Second*30), + ) } func (o *OAuth) StartElevatedAuthFlow(ctx context.Context, w http.ResponseWriter, r *http.Request, did string, extraScopes []string, returnURL string) (string, error) { diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -35,11 +35,26 @@ "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages/markup" "tangled.org/core/appview/pages/markup/sanitizer" + "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/crypto" "tangled.org/core/idresolver" + "tangled.org/core/orm" + "tangled.org/core/types" ) type tab map[string]string + +func (p *Pages) ownerSlashRepo(repo *models.Repo) string { + ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did) + if err != nil { + return repo.RepoIdentifier() + } + handle := ownerId.Handle + if handle != "" && !handle.IsInvalidHandle() { + return string(handle) + "/" + repo.Slug() + } + return repo.RepoIdentifier() +} func (p *Pages) funcMap() template.FuncMap { return template.FuncMap{ @@ -91,16 +106,42 @@ } return identity.PDSEndpoint() }, - "ownerSlashRepo": func(repo *models.Repo) string { - ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did) - if err != nil { - return repo.RepoIdentifier() + "ownerSlashRepo": p.ownerSlashRepo, + "pipelineCommitPath": func(repoInfo repoinfo.RepoInfo, pipeline types.Pipeline) string { + sha := pipeline.Sha() + if sourceRepo := pipeline.SourceRepo(); sourceRepo != nil { + if repo, err := db.GetRepoByDid(p.db, *sourceRepo); err == nil && repo != nil { + return "/" + p.ownerSlashRepo(repo) + "/commit/" + sha + } } - handle := ownerId.Handle - if handle != "" && !handle.IsInvalidHandle() { - return string(handle) + "/" + repo.Slug() + return "/" + repoInfo.FullName() + "/commit/" + sha + }, + "pipelineSourceLabel": func(pipeline types.Pipeline) string { + branch := pipeline.Trigger().PRSourceBranch() + if branch == "" { + return branch } - return repo.RepoIdentifier() + sourceRepo := pipeline.SourceRepo() + if sourceRepo == nil { + return branch + } + repo, err := db.GetRepoByDid(p.db, *sourceRepo) + if err != nil || repo == nil { + return branch + } + return p.ownerSlashRepo(repo) + "/" + branch + }, + "pipelinePullPath": func(pipeline types.Pipeline) string { + pullAtStr := pipeline.Trigger().PRUri() + if pullAtStr == "" { + return "" + } + // GetPull's reverse-mapping already populates pull.Repo + pull, err := db.GetPull(p.db, orm.FilterEq("at_uri", pullAtStr)) + if err != nil || pull == nil || pull.Repo == nil { + return "" + } + return fmt.Sprintf("/%s/pulls/%d", p.ownerSlashRepo(pull.Repo), pull.PullId) }, "truncateAt30": func(s string) string { if len(s) <= 30 { diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1440,6 +1440,12 @@ ActiveRound int IsInterdiff bool + // WorkflowsChanged and ChangedWorkflowFiles describe whether the latest + // round's patch touches .tangled/workflows/, for warning maintainers + // before they manually trigger CI on a fork-based pull request. + WorkflowsChanged bool + ChangedWorkflowFiles []string + Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData UserReacted map[syntax.ATURI]map[models.ReactionKind]bool diff --git a/appview/pipelines/pipelines.go b/appview/pipelines/pipelines.go --- a/appview/pipelines/pipelines.go +++ b/appview/pipelines/pipelines.go @@ -46,7 +46,7 @@ r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) r.Group(func(r chi.Router) { - r.Use(mw.RepoPermissionMiddleware("repo:owner")) + r.Use(mw.RepoPermissionMiddleware("repo:push")) r.Post("/{pipeline}/workflow/{workflow}/cancel", p.CancelWorkflow) r.Post("/{pipeline}/retry", p.RetryPipeline) r.Post("/{pipeline}/workflow/{workflow}/retry", p.RetryWorkflow) @@ -250,6 +250,50 @@ // Shutdown implements [lexutil.Scheduler]. func (w *webLogScheduler) Shutdown() { close(w.ch) } + +func retryPipelineTrigger(orig *tangled.CiPipeline) *tangled.CiTriggerPipeline_Input_Trigger { + if orig.Trigger != nil && orig.Trigger.CiTrigger_PullRequest != nil { + pr := orig.Trigger.CiTrigger_PullRequest + sourceSha := pr.SourceSha + if sourceSha == "" { + sourceSha = orig.Commit + } + sourceRepo := pr.SourceRepo + if sourceRepo == nil { + sourceRepo = orig.SourceRepo + } + + return &tangled.CiTriggerPipeline_Input_Trigger{ + CiTrigger_PullRequest: &tangled.CiTrigger_PullRequest{ + Pull: pr.Pull, + SourceBranch: pr.SourceBranch, + SourceRepo: sourceRepo, + SourceSha: sourceSha, + TargetBranch: pr.TargetBranch, + }, + } + } + + manual := &tangled.CiTrigger_Manual{ + Sha: orig.Commit, + SourceRepo: orig.SourceRepo, + } + if orig.Trigger != nil && orig.Trigger.CiTrigger_Manual != nil { + origManual := orig.Trigger.CiTrigger_Manual + if origManual.Sha != "" { + manual.Sha = origManual.Sha + } + manual.Ref = origManual.Ref + manual.Inputs = origManual.Inputs + if origManual.SourceRepo != nil { + manual.SourceRepo = origManual.SourceRepo + } + } + + return &tangled.CiTriggerPipeline_Input_Trigger{ + CiTrigger_Manual: manual, + } +} func (p *Pipelines) Logs(w http.ResponseWriter, r *http.Request) { l := p.logger.With("handler", "logs") @@ -466,20 +510,19 @@ l = l.With("pipeline", pipelineId, "workflow", workflowName) - spindleClient, err := p.spindleServiceClient(r, f.Spindle, tangled.CiPipelineCancelPipelineNSID) + spindleClient, err := p.oauth.SpindleServiceClient(r, f.Spindle, tangled.CiCancelPipelineNSID) if err != nil { l.Error("failed to prepare spindle client", "err", err) p.pages.Notice(w, errorId, "Failed to cancel workflow") return } - pipelineAtUri := fmt.Sprintf("at://did:web:%s/%s/%s", f.Knot, tangled.PipelineNSID, pipelineId.String()) - if err := tangled.CiPipelineCancelPipeline( + if err := tangled.CiCancelPipeline( r.Context(), spindleClient, - &tangled.CiPipelineCancelPipeline_Input{ - Repo: string(f.RepoAt()), - Pipeline: pipelineAtUri, + &tangled.CiCancelPipeline_Input{ + Repo: f.RepoDid, + Pipeline: pipelineId.String(), Workflows: []string{workflowName}, }, ); err != nil { @@ -570,7 +613,7 @@ } redirectWf := workflows[0] - spindleClient, err := p.spindleServiceClient(r, f.Spindle, tangled.CiTriggerPipelineNSID) + spindleClient, err := p.oauth.SpindleServiceClient(r, f.Spindle, tangled.CiTriggerPipelineNSID) if err != nil { fail("failed to authorize with spindle", err) return @@ -580,8 +623,8 @@ r.Context(), spindleClient, &tangled.CiTriggerPipeline_Input{ - Repo: string(f.RepoAt()), - Sha: orig.Commit, + Repo: f.RepoDid, + Trigger: retryPipelineTrigger(orig), Workflows: workflows, }, ) @@ -608,19 +651,4 @@ return } http.Redirect(w, r, dest, http.StatusSeeOther) -} - -// spindleServiceClient builds an authed spindle xrpc client -func (p *Pipelines) spindleServiceClient(r *http.Request, spindle, lxm string) (*indigoxrpc.Client, error) { - hostname, noTLS, err := hostutil.ParseHostname(spindle) - if err != nil { - return nil, err - } - return p.oauth.ServiceClient( - r, - oauth.WithService(hostname), - oauth.WithLxm(lxm), - oauth.WithDev(noTLS), - oauth.WithTimeout(time.Second*30), - ) } diff --git a/appview/pulls/compose.go b/appview/pulls/compose.go --- a/appview/pulls/compose.go +++ b/appview/pulls/compose.go @@ -18,9 +18,9 @@ "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup/sanitizer" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/patchutil" "tangled.org/core/types" + "tangled.org/core/xrpc/xrpcclient" "github.com/bluesky-social/indigo/atproto/syntax" indigoxrpc "github.com/bluesky-social/indigo/xrpc" diff --git a/appview/pulls/create.go b/appview/pulls/create.go --- a/appview/pulls/create.go +++ b/appview/pulls/create.go @@ -16,11 +16,11 @@ "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/reporesolver" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/patchutil" "tangled.org/core/tid" "tangled.org/core/types" "tangled.org/core/xrpc" + "tangled.org/core/xrpc/xrpcclient" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" diff --git a/appview/pulls/merge.go b/appview/pulls/merge.go --- a/appview/pulls/merge.go +++ b/appview/pulls/merge.go @@ -10,8 +10,8 @@ "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/reporesolver" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/orm" + "tangled.org/core/xrpc/xrpcclient" "github.com/bluesky-social/indigo/atproto/syntax" ) diff --git a/appview/pulls/resubmit.go b/appview/pulls/resubmit.go --- a/appview/pulls/resubmit.go +++ b/appview/pulls/resubmit.go @@ -13,11 +13,11 @@ "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/reporesolver" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/orm" "tangled.org/core/patchutil" "tangled.org/core/types" "tangled.org/core/xrpc" + "tangled.org/core/xrpc/xrpcclient" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" diff --git a/appview/pulls/router.go b/appview/pulls/router.go --- a/appview/pulls/router.go +++ b/appview/pulls/router.go @@ -50,6 +50,11 @@ r.Post("/merge", s.MergePull) // maybe lock, etc. }) + + r.Group(func(r chi.Router) { + r.Use(mw.RepoPermissionMiddleware("repo:push")) + r.Post("/trigger-ci", s.TriggerCi) + }) }) }) return r diff --git a/appview/pulls/single.go b/appview/pulls/single.go --- a/appview/pulls/single.go +++ b/appview/pulls/single.go @@ -10,10 +10,10 @@ "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/pages" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/orm" "tangled.org/core/patchutil" "tangled.org/core/types" + "tangled.org/core/xrpc/xrpcclient" "github.com/bluesky-social/indigo/atproto/syntax" indigoxrpc "github.com/bluesky-social/indigo/xrpc" @@ -187,6 +187,16 @@ return m }(r.Context()) + var workflowsChanged bool + var changedWorkflows []string + if _, hasPipeline := pipelines[pull.LatestSha()]; pull.IsForkBased() && !hasPipeline { + changedWorkflows, err = changedWorkflowFiles(pull.LatestSubmission().CombinedPatch()) + if err != nil { + l.Error("failed to inspect latest round's patch for workflow changes", "err", err) + } + workflowsChanged = len(changedWorkflows) > 0 + } + entities := []syntax.ATURI{pull.AtUri()} for _, s := range pull.Submissions { for _, c := range s.Comments { @@ -273,6 +283,9 @@ DiffOpts: diffOpts, ActiveRound: roundIdInt, IsInterdiff: interdiff, + + WorkflowsChanged: workflowsChanged, + ChangedWorkflowFiles: changedWorkflows, Reactions: reactions, UserReacted: userReactions, diff --git a/appview/pulls/trigger_ci.go b/appview/pulls/trigger_ci.go new file mode 100644 --- /dev/null +++ b/appview/pulls/trigger_ci.go @@ -0,0 +1,137 @@ +package pulls + +import ( + "fmt" + "net/http" + "strings" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/patchutil" + "tangled.org/core/workflow" +) + +func changedWorkflowFiles(patch string) ([]string, error) { + files, err := patchutil.AsDiff(patch) + if err != nil { + return nil, err + } + + var changed []string + for _, f := range files { + if f == nil { + continue + } + for _, name := range []string{f.NewName, f.OldName} { + if name != "" && strings.HasPrefix(name, workflow.WorkflowDir+"/") { + changed = append(changed, name) + break + } + } + } + return changed, nil +} + +// TriggerCi manually triggers a CI pipeline for a fork-based pull request. +// authorized against and recorded under the target repo, but checked out +// from the fork at the latest round's commit. +func (s *Pulls) TriggerCi(w http.ResponseWriter, r *http.Request) { + l := s.logger.With("handler", "TriggerCi") + errorId := "pull-error" + + fail := func(msg string, err error) { + if err != nil { + l.Error(msg, "err", err) + } else { + l.Error(msg) + } + s.pages.Notice(w, errorId, msg) + } + + f, err := s.repoResolver.Resolve(r) + if err != nil { + fail("failed to resolve repository", err) + return + } + + pull, ok := r.Context().Value("pull").(*models.Pull) + if !ok { + fail("failed to get pull", nil) + return + } + l = l.With("pull_id", pull.PullId) + + if !pull.IsForkBased() { + fail("this pull request is not fork-based", nil) + return + } + + if f.Spindle == "" { + fail("this repository has no spindle configured", nil) + return + } + + latest := pull.LatestSubmission() + if latest.SourceRev == "" { + fail("cannot trigger ci: this round has no commit to run", nil) + return + } + + changedFiles, err := changedWorkflowFiles(latest.CombinedPatch()) + if err != nil { + fail("failed to inspect the latest round's patch", err) + return + } + if len(changedFiles) > 0 && r.URL.Query().Get("confirm") != "1" { + fail(fmt.Sprintf("workflow files changed in this round (%s); review before running", strings.Join(changedFiles, ", ")), nil) + return + } + + forkRepo, err := db.GetRepoByDid(s.db, pull.PullSource.RepoDid.String()) + if err != nil { + fail("failed to resolve the fork this pull request comes from", err) + return + } + + spindleClient, err := s.oauth.SpindleServiceClient(r, f.Spindle, tangled.CiTriggerPipelineNSID) + if err != nil { + fail("failed to authorize with spindle", err) + return + } + + pullAt := pull.AtUri().String() + sourceBranch := pull.PullSource.Branch + targetBranch := pull.TargetBranch + out, err := tangled.CiTriggerPipeline( + r.Context(), + spindleClient, + &tangled.CiTriggerPipeline_Input{ + Repo: f.RepoDid, + Trigger: &tangled.CiTriggerPipeline_Input_Trigger{ + CiTrigger_PullRequest: &tangled.CiTrigger_PullRequest{ + Pull: &pullAt, + SourceBranch: &sourceBranch, + SourceRepo: &forkRepo.RepoDid, + SourceSha: latest.SourceRev, + TargetBranch: targetBranch, + }, + }, + }, + ) + if err != nil { + fail("spindle rejected the trigger", err) + return + } + l.Info("triggered ci for fork-based pull", "pipeline", out.Pipeline) + + user := s.oauth.GetMultiAccountUser(r) + repoInfo := s.repoResolver.GetRepoInfo(r, user) + dest := fmt.Sprintf("/%s/pulls/%d/round/%d", repoInfo.FullName(), pull.PullId, pull.LastRoundNumber()) + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", dest) + w.WriteHeader(http.StatusOK) + return + } + http.Redirect(w, r, dest, http.StatusSeeOther) +} diff --git a/appview/repo/artifact.go b/appview/repo/artifact.go --- a/appview/repo/artifact.go +++ b/appview/repo/artifact.go @@ -13,11 +13,11 @@ "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/pages" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/orm" "tangled.org/core/tid" "tangled.org/core/types" "tangled.org/core/xrpc" + "tangled.org/core/xrpc/xrpcclient" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" diff --git a/appview/repo/blob.go b/appview/repo/blob.go --- a/appview/repo/blob.go +++ b/appview/repo/blob.go @@ -18,8 +18,8 @@ "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup" "tangled.org/core/appview/reporesolver" - xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/types" + xrpcclient "tangled.org/core/xrpc/xrpcclient" "github.com/bluesky-social/indigo/util" indigoxrpc "github.com/bluesky-social/indigo/xrpc" diff --git a/appview/repo/branches.go b/appview/repo/branches.go --- a/appview/repo/branches.go +++ b/appview/repo/branches.go @@ -8,8 +8,8 @@ "tangled.org/core/api/tangled" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" - xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/types" + xrpcclient "tangled.org/core/xrpc/xrpcclient" indigoxrpc "github.com/bluesky-social/indigo/xrpc" ) diff --git a/appview/repo/compare.go b/appview/repo/compare.go --- a/appview/repo/compare.go +++ b/appview/repo/compare.go @@ -10,9 +10,9 @@ "tangled.org/core/api/tangled" "tangled.org/core/appview/pages" - xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/patchutil" "tangled.org/core/types" + xrpcclient "tangled.org/core/xrpc/xrpcclient" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" diff --git a/appview/repo/log.go b/appview/repo/log.go --- a/appview/repo/log.go +++ b/appview/repo/log.go @@ -12,8 +12,8 @@ "tangled.org/core/appview/commitverify" "tangled.org/core/appview/db" "tangled.org/core/appview/pages" - xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/types" + xrpcclient "tangled.org/core/xrpc/xrpcclient" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" diff --git a/appview/repo/repo.go b/appview/repo/repo.go --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -27,7 +27,6 @@ "tangled.org/core/appview/pagination" "tangled.org/core/appview/reporesolver" "tangled.org/core/appview/sites" - xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/consts" "tangled.org/core/idresolver" "tangled.org/core/ogre" @@ -35,6 +34,7 @@ "tangled.org/core/rbac" "tangled.org/core/tid" "tangled.org/core/xrpc/serviceauth" + xrpcclient "tangled.org/core/xrpc/xrpcclient" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atclient" diff --git a/appview/repo/settings.go b/appview/repo/settings.go --- a/appview/repo/settings.go +++ b/appview/repo/settings.go @@ -20,11 +20,11 @@ "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/sites" - xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/consts" "tangled.org/core/orm" "tangled.org/core/sets" "tangled.org/core/types" + xrpcclient "tangled.org/core/xrpc/xrpcclient" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" diff --git a/appview/repo/tags.go b/appview/repo/tags.go --- a/appview/repo/tags.go +++ b/appview/repo/tags.go @@ -11,9 +11,9 @@ "tangled.org/core/appview/models" "tangled.org/core/appview/pages" "tangled.org/core/appview/reporesolver" - xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/orm" "tangled.org/core/types" + xrpcclient "tangled.org/core/xrpc/xrpcclient" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" diff --git a/appview/repo/tree.go b/appview/repo/tree.go --- a/appview/repo/tree.go +++ b/appview/repo/tree.go @@ -13,8 +13,8 @@ "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup" "tangled.org/core/appview/reporesolver" - xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/types" + xrpcclient "tangled.org/core/xrpc/xrpcclient" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" diff --git a/appview/repoverify/verify.go b/appview/repoverify/verify.go deleted file mode 100644 --- a/appview/repoverify/verify.go +++ /dev/null @@ -1,159 +0,0 @@ -package repoverify - -import ( - "context" - "errors" - "fmt" - "net" - "net/http" - "net/url" - "syscall" - "time" - - "github.com/bluesky-social/indigo/atproto/syntax" - indigoxrpc "github.com/bluesky-social/indigo/xrpc" - "tangled.org/core/api/tangled" - "tangled.org/core/appview/xrpcclient" - "tangled.org/core/idresolver" -) - -type RepoDid syntax.DID - -func (r RepoDid) String() string { return string(r) } - -func NewRepoDid(s string) (RepoDid, error) { - did, err := syntax.ParseDID(s) - if err != nil { - return "", fmt.Errorf("invalid repoDid %q: %w", s, err) - } - return RepoDid(did), nil -} - -type OwnerDid syntax.DID - -func (o OwnerDid) String() string { return string(o) } - -func NewOwnerDid(s string) (OwnerDid, error) { - did, err := syntax.ParseDID(s) - if err != nil { - return "", fmt.Errorf("invalid ownerDid %q: %w", s, err) - } - return OwnerDid(did), nil -} - -func ParseKnotEndpoint(raw string, dev bool) (*url.URL, error) { - if raw == "" { - return nil, fmt.Errorf("empty knot URL") - } - u, err := url.Parse(raw) - if err != nil { - return nil, fmt.Errorf("invalid knot URL %q: %w", raw, err) - } - if u.Host == "" { - return nil, fmt.Errorf("knot URL %q has no host", raw) - } - switch u.Scheme { - case "https": - case "http": - if !dev { - return nil, fmt.Errorf("knot URL %q must use https outside dev mode", raw) - } - default: - return nil, fmt.Errorf("knot URL %q has unsupported scheme %q", raw, u.Scheme) - } - return u, nil -} - -type Result struct { - RepoDid RepoDid - OwnerDid OwnerDid - KnotURL *url.URL -} - -type Verifier func(ctx context.Context, repoDid RepoDid) (Result, error) - -const verifyTimeout = 10 * time.Second - -func New(resolver *idresolver.Resolver, dev bool) Verifier { - transport := &http.Transport{ - DialContext: safeDialer(dev).DialContext, - } - httpClient := &http.Client{ - Timeout: verifyTimeout, - Transport: transport, - } - - return func(ctx context.Context, repoDid RepoDid) (Result, error) { - ctx, cancel := context.WithTimeout(ctx, verifyTimeout) - defer cancel() - return resolveAndDescribe(ctx, resolver, httpClient, repoDid, dev) - } -} - -func resolveAndDescribe( - ctx context.Context, - resolver *idresolver.Resolver, - httpClient *http.Client, - repoDid RepoDid, - dev bool, -) (Result, error) { - ident, err := resolver.ResolveIdent(ctx, repoDid.String()) - if err != nil { - return Result{}, fmt.Errorf("resolve repoDid %s: %w", repoDid, err) - } - - knot, err := ParseKnotEndpoint(ident.GetServiceEndpoint("atproto_pds"), dev) - if err != nil { - return Result{}, fmt.Errorf("repoDid %s: %w", repoDid, err) - } - - client := &indigoxrpc.Client{Host: knot.String(), Client: httpClient} - out, err := tangled.RepoDescribeRepo(ctx, client, repoDid.String()) - if xrpcErr := xrpcclient.HandleXrpcErr(err); xrpcErr != nil { - if errors.Is(xrpcErr, xrpcclient.ErrXrpcUnsupported) { - return Result{RepoDid: repoDid, KnotURL: knot}, nil - } - return Result{}, fmt.Errorf("describeRepo on %s: %w", knot, xrpcErr) - } - - if out.RepoDid != repoDid.String() { - return Result{}, fmt.Errorf("knot %s returned mismatched repoDid: got %q, want %q", knot, out.RepoDid, repoDid) - } - - ownerDid, err := NewOwnerDid(out.OwnerDid) - if err != nil { - return Result{}, fmt.Errorf("describeRepo on %s returned invalid ownerDid: %w", knot, err) - } - - return Result{ - RepoDid: repoDid, - OwnerDid: ownerDid, - KnotURL: knot, - }, nil -} - -func safeDialer(dev bool) *net.Dialer { - d := &net.Dialer{ - Timeout: 5 * time.Second, - KeepAlive: 30 * time.Second, - } - if dev { - return d - } - d.Control = func(network, address string, _ syscall.RawConn) error { - host, _, err := net.SplitHostPort(address) - if err != nil { - return fmt.Errorf("invalid dial address %q: %w", address, err) - } - ip := net.ParseIP(host) - if ip == nil { - return fmt.Errorf("dial address %q did not resolve to IP", address) - } - if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || - ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { - return fmt.Errorf("refusing to dial %s: reserved or private address", ip) - } - return nil - } - return d -} diff --git a/appview/repoverify/verify_test.go b/appview/repoverify/verify_test.go deleted file mode 100644 --- a/appview/repoverify/verify_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package repoverify - -import "testing" - -func TestNewRepoDid_RejectsInvalid(t *testing.T) { - if _, err := NewRepoDid(""); err == nil { - t.Error("NewRepoDid(\"\") err = nil, want error") - } -} - -func TestNewRepoDid_AcceptsValid(t *testing.T) { - raw := "did:plc:abc123abc123abc123abc123" - got, err := NewRepoDid(raw) - if err != nil { - t.Fatalf("NewRepoDid(%q): %v", raw, err) - } - if got.String() != raw { - t.Errorf("got %q, want %q", got, raw) - } -} - -func TestParseKnotEndpoint_RejectsHttpInProd(t *testing.T) { - if _, err := ParseKnotEndpoint("http://knot.example", false); err == nil { - t.Error("http:// knot URL accepted in prod") - } -} - -func TestParseKnotEndpoint_AllowsHttpInDev(t *testing.T) { - u, err := ParseKnotEndpoint("http://knot.example", true) - if err != nil { - t.Fatalf("dev mode should allow http: %v", err) - } - if u.Host != "knot.example" { - t.Errorf("Host = %q, want knot.example", u.Host) - } -} - -func TestParseKnotEndpoint_RejectsUnsupportedScheme(t *testing.T) { - if _, err := ParseKnotEndpoint("ftp://knot.example", true); err == nil { - t.Error("ParseKnotEndpoint accepted ftp:// in dev") - } - if _, err := ParseKnotEndpoint("ftp://knot.example", false); err == nil { - t.Error("ParseKnotEndpoint accepted ftp:// in prod") - } -} - -func TestParseKnotEndpoint_RejectsEmptyOrHostless(t *testing.T) { - cases := []string{"", "https://", "not a url at all"} - for _, raw := range cases { - t.Run(raw, func(t *testing.T) { - if _, err := ParseKnotEndpoint(raw, false); err == nil { - t.Errorf("ParseKnotEndpoint(%q) accepted bogus URL", raw) - } - }) - } -} - -func TestParseKnotEndpoint_HostPreservesPort(t *testing.T) { - u, err := ParseKnotEndpoint("http://localhost:3000", true) - if err != nil { - t.Fatalf("ParseKnotEndpoint: %v", err) - } - if u.Host != "localhost:3000" { - t.Errorf("Host = %q, want localhost:3000", u.Host) - } -} diff --git a/appview/serververify/verify.go b/appview/serververify/verify.go --- a/appview/serververify/verify.go +++ b/appview/serververify/verify.go @@ -8,9 +8,9 @@ indigoxrpc "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/orm" "tangled.org/core/rbac" + "tangled.org/core/xrpc/xrpcclient" ) var ( diff --git a/appview/spindles/spindles.go b/appview/spindles/spindles.go --- a/appview/spindles/spindles.go +++ b/appview/spindles/spindles.go @@ -18,11 +18,11 @@ "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/serververify" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/idresolver" "tangled.org/core/orm" "tangled.org/core/rbac" "tangled.org/core/tid" + "tangled.org/core/xrpc/xrpcclient" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" diff --git a/appview/state/state.go b/appview/state/state.go --- a/appview/state/state.go +++ b/appview/state/state.go @@ -33,8 +33,6 @@ "tangled.org/core/appview/pages" pipelinessh "tangled.org/core/appview/pipelines/ssh" "tangled.org/core/appview/reporesolver" - "tangled.org/core/appview/repoverify" - xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/consts" "tangled.org/core/eventconsumer" "tangled.org/core/idresolver" @@ -43,6 +41,8 @@ tlog "tangled.org/core/log" "tangled.org/core/orm" "tangled.org/core/rbac" + "tangled.org/core/repoverify" + xrpcclient "tangled.org/core/xrpc/xrpcclient" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atclient" diff --git a/appview/xrpcclient/xrpc.go b/appview/xrpcclient/xrpc.go deleted file mode 100644 --- a/appview/xrpcclient/xrpc.go +++ /dev/null @@ -1,39 +0,0 @@ -package xrpcclient - -import ( - "errors" - "net/http" - - indigoxrpc "github.com/bluesky-social/indigo/xrpc" -) - -var ( - ErrXrpcUnsupported = errors.New("xrpc not supported on this knot") - ErrXrpcUnauthorized = errors.New("unauthorized xrpc request") - ErrXrpcForbidden = errors.New("forbidden xrpc request") - ErrXrpcFailed = errors.New("xrpc request failed") - ErrXrpcInvalid = errors.New("invalid xrpc request") -) - -// produces a more manageable error -func HandleXrpcErr(err error) error { - if err == nil { - return nil - } - - var xrpcerr *indigoxrpc.Error - if ok := errors.As(err, &xrpcerr); !ok { - return ErrXrpcInvalid - } - - switch xrpcerr.StatusCode { - case http.StatusNotFound: - return ErrXrpcUnsupported - case http.StatusUnauthorized: - return ErrXrpcUnauthorized - case http.StatusForbidden: - return ErrXrpcForbidden - default: - return ErrXrpcFailed - } -} diff --git a/cmd/cborgen/cborgen.go b/cmd/cborgen/cborgen.go --- a/cmd/cborgen/cborgen.go +++ b/cmd/cborgen/cborgen.go @@ -21,6 +21,7 @@ tangled.CiSubscribePipelineLogs_Control{}, tangled.CiSubscribePipelineLogs_Data{}, tangled.CiTrigger_Manual{}, + tangled.CiTrigger_Pair{}, tangled.CiTrigger_PullRequest{}, tangled.CiTrigger_Push{}, tangled.FeedComment{}, diff --git a/lexicons/ci/pipeline.json b/lexicons/ci/pipeline.json --- a/lexicons/ci/pipeline.json +++ b/lexicons/ci/pipeline.json @@ -29,6 +29,11 @@ "type": "string", "description": "Commit Id this pipeline is running on" }, + "sourceRepo": { + "type": "string", + "format": "did", + "description": "Repository DID that the commit was checked out from, if different from repo (e.g. a fork for a fork-based pull request)" + }, "createdAt": { "type": "string", "format": "datetime" diff --git a/lexicons/ci/trigger.json b/lexicons/ci/trigger.json --- a/lexicons/ci/trigger.json +++ b/lexicons/ci/trigger.json @@ -23,9 +23,14 @@ }, "pullRequest": { "type": "object", - "required": ["targetBranch", "sourceSha", "action"], + "required": ["targetBranch", "sourceSha"], "description": "TODO: reference PR record with strongRef instead of embedding raw values", "properties": { + "sourceRepo": { + "type": "string", + "format": "did", + "description": "Repository DID to check out code and workflow definitions from, if different from the target repo." + }, "sourceBranch": { "type": "string" }, @@ -37,14 +42,52 @@ "minLength": 40, "maxLength": 40 }, - "action": { - "type": "string" + "pull": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the sh.tangled.repo.pull record this run belongs to" } } }, "manual": { "type": "object", - "properties": {} + "required": ["sha"], + "properties": { + "sha": { + "type": "string", + "description": "commit SHA the manual run targets", + "minLength": 40, + "maxLength": 40 + }, + "ref": { + "type": "string", + "description": "optional ref the SHA was resolved from, for display and TANGLED_REF" + }, + "sourceRepo": { + "type": "string", + "format": "did", + "description": "Repository DID to check out code and workflow definitions from, if different from the target repo." + }, + "inputs": { + "type": "array", + "items": { + "type": "ref", + "ref": "#pair" + } + } + } + }, + "pair": { + "type": "object", + "required": ["key", "value"], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } } } } diff --git a/lexicons/ci/triggerPipeline.json b/lexicons/ci/triggerPipeline.json --- a/lexicons/ci/triggerPipeline.json +++ b/lexicons/ci/triggerPipeline.json @@ -4,27 +4,25 @@ "defs": { "main": { "type": "procedure", - "description": "Manually trigger a pipeline at an explicit commit. Runs the named workflows, or every workflow defined in the repo when none are named.", + "description": "Trigger a pipeline at an explicit commit. Runs the named workflows, or every workflow defined in the repo when none are named.", "input": { "encoding": "application/json", "schema": { "type": "object", - "required": ["repo", "sha"], + "required": ["repo", "trigger"], "properties": { "repo": { "type": "string", - "format": "at-uri", - "description": "AT-URI of the sh.tangled.repo record" + "format": "did", + "description": "Target repository DID. Auth is checked against this repo." }, - "sha": { - "type": "string", - "minLength": 40, - "maxLength": 40, - "description": "commit SHA to run the pipeline at" - }, - "ref": { - "type": "string", - "description": "optional ref the SHA was resolved from, for display" + "trigger": { + "type": "union", + "refs": [ + "sh.tangled.ci.trigger#manual", + "sh.tangled.ci.trigger#pullRequest" + ], + "description": "Trigger metadata for this dispatch." }, "workflows": { "type": "array", diff --git a/lexicons/pipeline/pipeline.json b/lexicons/pipeline/pipeline.json --- a/lexicons/pipeline/pipeline.json +++ b/lexicons/pipeline/pipeline.json @@ -59,6 +59,11 @@ "manual": { "type": "ref", "ref": "#manualTriggerData" + }, + "sourceRepo": { + "type": "string", + "format": "did", + "description": "Repository DID that code and workflow definitions are checked out from, when different from repo (e.g. a fork's commit for a fork-based manual trigger). If absent, source uses repo itself." } } }, @@ -118,8 +123,7 @@ "required": [ "sourceBranch", "targetBranch", - "sourceSha", - "action" + "sourceSha" ], "properties": { "sourceBranch": { @@ -133,8 +137,10 @@ "minLength": 40, "maxLength": 40 }, - "action": { - "type": "string" + "pull": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the sh.tangled.repo.pull record this run belongs to" } } }, diff --git a/spindle/db/pipelines.go b/spindle/db/pipelines.go --- a/spindle/db/pipelines.go +++ b/spindle/db/pipelines.go @@ -9,6 +9,7 @@ "tangled.org/core/api/tangled" "tangled.org/core/spindle/models" + "tangled.org/core/workflow" ) func (d *DB) QueryPipelines(ctx context.Context, repoDid string, commits []string, cursor string, limit int) ([]*tangled.CiPipeline, string, int64, error) { @@ -17,7 +18,7 @@ } var query string - var args []interface{} + var args []any query = ` select rkey, event, created from events @@ -33,7 +34,11 @@ placeholders[i] = "?" args = append(args, commits[i]) } - query += " and json_extract(event, '$.triggerMetadata.push.newSha') in (" + strings.Join(placeholders, ",") + ")" + query += ` and coalesce( + json_extract(event, '$.triggerMetadata.push.newSha'), + json_extract(event, '$.triggerMetadata.pullRequest.sourceSha'), + json_extract(event, '$.triggerMetadata.manual.sha') + ) in (` + strings.Join(placeholders, ",") + ")" } if cursor != "" { @@ -75,7 +80,7 @@ continue } - p, err := d.mapToCiPipeline(ctx, rkey, created, rawPipeline) + p, err := d.mapToCiPipeline(rkey, created, rawPipeline) if err != nil { return nil, "", 0, err } @@ -113,10 +118,10 @@ return nil, err } - return d.mapToCiPipeline(ctx, rkey, created, rawPipeline) + return d.mapToCiPipeline(rkey, created, rawPipeline) } -func (d *DB) mapToCiPipeline(ctx context.Context, rkey string, created int64, raw tangled.Pipeline) (*tangled.CiPipeline, error) { +func (d *DB) mapToCiPipeline(rkey string, created int64, raw tangled.Pipeline) (*tangled.CiPipeline, error) { createdAtStr := time.Unix(0, created).Format(time.RFC3339) var repoDidStr string @@ -132,8 +137,8 @@ var trigger tangled.CiPipeline_Trigger if raw.TriggerMetadata != nil { - switch raw.TriggerMetadata.Kind { - case "push": + switch workflow.TriggerKind(raw.TriggerMetadata.Kind) { + case workflow.TriggerKindPush: if raw.TriggerMetadata.Push != nil { commitSha = raw.TriggerMetadata.Push.NewSha trigger.CiTrigger_Push = &tangled.CiTrigger_Push{ @@ -142,20 +147,26 @@ Ref: raw.TriggerMetadata.Push.Ref, } } - case "pullRequest": + case workflow.TriggerKindPullRequest: if raw.TriggerMetadata.PullRequest != nil { commitSha = raw.TriggerMetadata.PullRequest.SourceSha trigger.CiTrigger_PullRequest = &tangled.CiTrigger_PullRequest{ - Action: raw.TriggerMetadata.PullRequest.Action, SourceBranch: &raw.TriggerMetadata.PullRequest.SourceBranch, + SourceRepo: raw.TriggerMetadata.SourceRepo, SourceSha: raw.TriggerMetadata.PullRequest.SourceSha, TargetBranch: raw.TriggerMetadata.PullRequest.TargetBranch, + Pull: raw.TriggerMetadata.PullRequest.Pull, } } - case "manual": + case workflow.TriggerKindManual: if raw.TriggerMetadata.Manual != nil { commitSha = raw.TriggerMetadata.Manual.Sha - trigger.CiTrigger_Manual = &tangled.CiTrigger_Manual{} + trigger.CiTrigger_Manual = &tangled.CiTrigger_Manual{ + Inputs: pipelinePairsToCiTriggerPairs(raw.TriggerMetadata.Manual.Inputs), + Ref: raw.TriggerMetadata.Manual.Ref, + Sha: raw.TriggerMetadata.Manual.Sha, + SourceRepo: raw.TriggerMetadata.SourceRepo, + } } } } @@ -192,14 +203,37 @@ }) } + var sourceRepo *string + if raw.TriggerMetadata != nil { + sourceRepo = raw.TriggerMetadata.SourceRepo + } + return &tangled.CiPipeline{ - Id: rkey, - Commit: commitSha, - Repo: &repoDidStr, - CreatedAt: &createdAtStr, - Trigger: &trigger, - Workflows: workflows, + Id: rkey, + Commit: commitSha, + Repo: &repoDidStr, + CreatedAt: &createdAtStr, + Trigger: &trigger, + Workflows: workflows, + SourceRepo: sourceRepo, }, nil +} + +func pipelinePairsToCiTriggerPairs(inputs []*tangled.Pipeline_Pair) []*tangled.CiTrigger_Pair { + if len(inputs) == 0 { + return nil + } + pairs := make([]*tangled.CiTrigger_Pair, 0, len(inputs)) + for _, input := range inputs { + if input == nil { + continue + } + pairs = append(pairs, &tangled.CiTrigger_Pair{ + Key: input.Key, + Value: input.Value, + }) + } + return pairs } func (d *DB) GetWorkflowTimes(workflowId models.WorkflowId) (startedAt, finishedAt *string) { diff --git a/spindle/engine/engine.go b/spindle/engine/engine.go --- a/spindle/engine/engine.go +++ b/spindle/engine/engine.go @@ -27,12 +27,14 @@ func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { l.Info("starting all workflows in parallel", "pipeline", pipelineId) - // extract secrets var allSecrets []secrets.UnlockedSecret - if pipeline.RepoDid != "" { + // never pass secrets to pipelines that run untrusted (e.g. fork) code + if pipeline.TrustedSource && pipeline.RepoDid != "" { if res, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(pipeline.RepoDid.String())); err == nil { allSecrets = res } + } else if !pipeline.TrustedSource { + l.Info("skipping secrets for untrusted pipeline source", "pipeline", pipelineId) } secretValues := make([]string, len(allSecrets)) @@ -51,10 +53,7 @@ l.Info("using workflow timeout", "timeout", workflowTimeout) for _, w := range wfs { - wg.Add(1) - go func() { - defer wg.Done() - + wg.Go(func() { wid := models.WorkflowId{ PipelineId: pipelineId, Name: w.Name, @@ -169,7 +168,7 @@ if err != nil { l.Error("failed to set workflow status to success", "wid", wid, "err", err) } - }() + }) } } diff --git a/spindle/models/clone.go b/spindle/models/clone.go --- a/spindle/models/clone.go +++ b/spindle/models/clone.go @@ -111,16 +111,14 @@ // BuildRepoURL constructs the repository URL from repo metadata. func BuildRepoURL(repo *tangled.Pipeline_TriggerRepo) string { - if repo == nil { + if repo == nil || repo.RepoDid == nil { return "" } - host, noSSL, _ := hostutil.ParseHostname(repo.Knot) scheme := "https" if noSSL { scheme = "http" } - return fmt.Sprintf("%s://%s/%s", scheme, host, *repo.RepoDid) } diff --git a/spindle/models/clone_test.go b/spindle/models/clone_test.go --- a/spindle/models/clone_test.go +++ b/spindle/models/clone_test.go @@ -83,7 +83,6 @@ SourceSha: "pr-sha-789", SourceBranch: "feature-branch", TargetBranch: "main", - Action: "opened", }, Repo: &tangled.Pipeline_TriggerRepo{ Knot: "example.com", @@ -98,6 +97,41 @@ allCmds := strings.Join(step.Commands(), " ") if !strings.Contains(allCmds, "pr-sha-789") { t.Error("Commands should contain PR commit SHA") + } +} + +func TestBuildCloneStep_SourceRepo(t *testing.T) { + twf := tangled.Pipeline_Workflow{ + Clone: &tangled.Pipeline_CloneOpts{ + Depth: 1, + Skip: false, + }, + } + sourceRepoDid := "did:plc:fork" + tr := tangled.Pipeline_TriggerMetadata{ + Kind: string(workflow.TriggerKindPullRequest), + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ + SourceSha: "pr-sha-789", + SourceBranch: "feature-branch", + TargetBranch: "main", + }, + Repo: &tangled.Pipeline_TriggerRepo{ + Knot: "fork.example.com", + Did: "did:plc:user456", + Repo: sp("fork-repo"), + RepoDid: &sourceRepoDid, + }, + SourceRepo: &sourceRepoDid, + } + + step := BuildCloneStep(twf, tr, false) + + allCmds := strings.Join(step.Commands(), " ") + if !strings.Contains(allCmds, "https://fork.example.com/did:plc:fork") { + t.Error("Commands should clone from source repo URL") + } + if strings.Contains(allCmds, "https://target.example.com/did:plc:target") { + t.Error("Commands should not clone from target repo URL when sourceRepo is set") } } diff --git a/spindle/models/pipeline.go b/spindle/models/pipeline.go --- a/spindle/models/pipeline.go +++ b/spindle/models/pipeline.go @@ -5,6 +5,8 @@ type Pipeline struct { RepoDid syntax.DID Workflows map[Engine][]Workflow + // whether the code being ran was checked out from RepoDid itself + TrustedSource bool } type Step interface { diff --git a/spindle/models/pipeline_env.go b/spindle/models/pipeline_env.go --- a/spindle/models/pipeline_env.go +++ b/spindle/models/pipeline_env.go @@ -10,6 +10,10 @@ // PipelineEnvVars builds the standard CI environment variables for a pipeline func PipelineEnvVars(tr *tangled.Pipeline_TriggerMetadata, pipelineId PipelineId) map[string]string { + return PipelineEnvVarsForSource(tr, pipelineId, nil) +} + +func PipelineEnvVarsForSource(tr *tangled.Pipeline_TriggerMetadata, pipelineId PipelineId, sourceRepo *tangled.Pipeline_TriggerRepo) map[string]string { if tr == nil { return nil } @@ -22,18 +26,28 @@ env["TANGLED_PIPELINE_ID"] = pipelineId.AtUri().String() env["TANGLED_PIPELINE_KIND"] = tr.Kind - // repo info - if tr.Repo != nil { - env["TANGLED_REPO_KNOT"] = tr.Repo.Knot - env["TANGLED_REPO_DID"] = tr.Repo.Did - if tr.Repo.Repo != nil { - env["TANGLED_REPO_NAME"] = *tr.Repo.Repo + if tr.SourceRepo != nil && *tr.SourceRepo != "" { + env["TANGLED_PIPELINE_SOURCE"] = *tr.SourceRepo + } else if tr.Repo != nil && tr.Repo.RepoDid != nil { + env["TANGLED_PIPELINE_SOURCE"] = *tr.Repo.RepoDid + } + + // prefer source repo, e.g. if this is a wf running on a fork + repoInfo := tr.Repo + if sourceRepo != nil { + repoInfo = sourceRepo + } + if repoInfo != nil { + env["TANGLED_REPO_KNOT"] = repoInfo.Knot + env["TANGLED_REPO_DID"] = repoInfo.Did + if repoInfo.Repo != nil { + env["TANGLED_REPO_NAME"] = *repoInfo.Repo } - if tr.Repo.RepoDid != nil { - env["TANGLED_REPO_REPO_DID"] = *tr.Repo.RepoDid + if repoInfo.RepoDid != nil { + env["TANGLED_REPO_REPO_DID"] = *repoInfo.RepoDid } - env["TANGLED_REPO_DEFAULT_BRANCH"] = tr.Repo.DefaultBranch - env["TANGLED_REPO_URL"] = BuildRepoURL(tr.Repo) + env["TANGLED_REPO_DEFAULT_BRANCH"] = repoInfo.DefaultBranch + env["TANGLED_REPO_URL"] = BuildRepoURL(repoInfo) } switch workflow.TriggerKind(tr.Kind) { @@ -62,10 +76,11 @@ env["TANGLED_COMMIT_SHA"] = tr.PullRequest.SourceSha // PR-specific env vars + env["TANGLED_PIPELINE_SOURCE_BRANCH"] = tr.PullRequest.SourceBranch + env["TANGLED_PIPELINE_TARGET_BRANCH"] = tr.PullRequest.TargetBranch env["TANGLED_PR_SOURCE_BRANCH"] = tr.PullRequest.SourceBranch env["TANGLED_PR_TARGET_BRANCH"] = tr.PullRequest.TargetBranch env["TANGLED_PR_SOURCE_SHA"] = tr.PullRequest.SourceSha - env["TANGLED_PR_ACTION"] = tr.PullRequest.Action } case workflow.TriggerKindManual: diff --git a/spindle/models/pipeline_env_test.go b/spindle/models/pipeline_env_test.go --- a/spindle/models/pipeline_env_test.go +++ b/spindle/models/pipeline_env_test.go @@ -110,7 +110,6 @@ SourceBranch: "feature-branch", TargetBranch: "main", SourceSha: "pr-sha-789", - Action: "opened", }, Repo: &tangled.Pipeline_TriggerRepo{ Knot: "example.com", @@ -145,6 +144,12 @@ } // Check PR-specific variables + if env["TANGLED_PIPELINE_SOURCE_BRANCH"] != "feature-branch" { + t.Errorf("Expected TANGLED_PIPELINE_SOURCE_BRANCH='feature-branch', got '%s'", env["TANGLED_PIPELINE_SOURCE_BRANCH"]) + } + if env["TANGLED_PIPELINE_TARGET_BRANCH"] != "main" { + t.Errorf("Expected TANGLED_PIPELINE_TARGET_BRANCH='main', got '%s'", env["TANGLED_PIPELINE_TARGET_BRANCH"]) + } if env["TANGLED_PR_SOURCE_BRANCH"] != "feature-branch" { t.Errorf("Expected TANGLED_PR_SOURCE_BRANCH='feature-branch', got '%s'", env["TANGLED_PR_SOURCE_BRANCH"]) } @@ -154,8 +159,47 @@ if env["TANGLED_PR_SOURCE_SHA"] != "pr-sha-789" { t.Errorf("Expected TANGLED_PR_SOURCE_SHA='pr-sha-789', got '%s'", env["TANGLED_PR_SOURCE_SHA"]) } - if env["TANGLED_PR_ACTION"] != "opened" { - t.Errorf("Expected TANGLED_PR_ACTION='opened', got '%s'", env["TANGLED_PR_ACTION"]) +} + +func TestPipelineEnvVars_SourceRepo(t *testing.T) { + sourceRepoDid := "did:plc:fork" + tr := &tangled.Pipeline_TriggerMetadata{ + Kind: string(workflow.TriggerKindPullRequest), + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ + SourceBranch: "feature-branch", + TargetBranch: "main", + SourceSha: "pr-sha-789", + }, + Repo: &tangled.Pipeline_TriggerRepo{ + Knot: "target.example.com", + Did: "did:plc:user123", + Repo: sp("target-repo"), + RepoDid: sp("did:plc:target"), + }, + SourceRepo: &sourceRepoDid, + } + sourceRepo := &tangled.Pipeline_TriggerRepo{ + Knot: "fork.example.com", + Did: "did:plc:user456", + Repo: sp("fork-repo"), + RepoDid: &sourceRepoDid, + DefaultBranch: "feature-branch", + } + id := PipelineId{ + Knot: "target.example.com", + Rkey: "123123", + } + + env := PipelineEnvVarsForSource(tr, id, sourceRepo) + + if env["TANGLED_PIPELINE_SOURCE"] != sourceRepoDid { + t.Errorf("Expected TANGLED_PIPELINE_SOURCE='%s', got '%s'", sourceRepoDid, env["TANGLED_PIPELINE_SOURCE"]) + } + if env["TANGLED_REPO_URL"] != "https://fork.example.com/did:plc:fork" { + t.Errorf("Expected TANGLED_REPO_URL to point at source repo, got '%s'", env["TANGLED_REPO_URL"]) + } + if env["TANGLED_REPO_REPO_DID"] != sourceRepoDid { + t.Errorf("Expected TANGLED_REPO_REPO_DID='%s', got '%s'", sourceRepoDid, env["TANGLED_REPO_REPO_DID"]) } } diff --git a/spindle/xrpc/ci_pipeline_trigger_pipeline.go b/spindle/xrpc/ci_pipeline_trigger_pipeline.go --- a/spindle/xrpc/ci_pipeline_trigger_pipeline.go +++ b/spindle/xrpc/ci_pipeline_trigger_pipeline.go @@ -7,9 +7,7 @@ "fmt" "net/http" - "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" "tangled.org/core/rbac" @@ -36,23 +34,83 @@ return } - if len(input.Sha) != 40 { - fail(xrpcerr.GenericError(fmt.Errorf("sha must be a 40-character commit hash"))) - return - } - repoDid, xerr, ok := x.resolveOwnedRepo(r.Context(), actorDid, input.Repo) if !ok { fail(xerr) return } + var sha string ref := "" - if input.Ref != nil { - ref = *input.Ref + var sourceRepo syntax.DID + var pull PullContext + var inputs []*tangled.Pipeline_Pair + + switch { + case input.Trigger == nil: + fail(xrpcerr.GenericError(fmt.Errorf("trigger is required"))) + return + + case input.Trigger.CiTrigger_Manual != nil: + manual := input.Trigger.CiTrigger_Manual + sha = manual.Sha + if manual.Ref != nil { + ref = *manual.Ref + } + parsed, err := parseOptionalDID("sourceRepo", manual.SourceRepo) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + sourceRepo = parsed + inputs = ciTriggerPairsToPipelinePairs(manual.Inputs) + + case input.Trigger.CiTrigger_PullRequest != nil: + pr := input.Trigger.CiTrigger_PullRequest + sha = pr.SourceSha + parsed, err := parseOptionalDID("sourceRepo", pr.SourceRepo) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + sourceRepo = parsed + + if pr.TargetBranch == "" { + fail(xrpcerr.GenericError(fmt.Errorf("pull request trigger targetBranch is required"))) + return + } + + var pullAt syntax.ATURI + if pr.Pull != nil { + var err error + pullAt, err = syntax.ParseATURI(*pr.Pull) + if err != nil { + fail(xrpcerr.InvalidRepoError(*pr.Pull)) + return + } + } + sourceBranch := "" + if pr.SourceBranch != nil { + sourceBranch = *pr.SourceBranch + } + pull = PullContext{ + IsPullRequest: true, + Pull: pullAt, + SourceBranch: sourceBranch, + TargetBranch: pr.TargetBranch, + } + + default: + fail(xrpcerr.GenericError(fmt.Errorf("unsupported trigger variant"))) + return } - pipelineAt, err := x.Trigger.TriggerManual(r.Context(), repoDid, input.Sha, ref, input.Workflows) + if len(sha) != 40 { + fail(xrpcerr.GenericError(fmt.Errorf("sha must be a 40-character commit hash"))) + return + } + + pipelineAt, err := x.Trigger.TriggerManual(r.Context(), repoDid, sha, ref, input.Workflows, sourceRepo, pull, inputs) if errors.Is(err, ErrNoMatchingWorkflows) { fail(xrpcerr.GenericError(err)) return @@ -69,37 +127,58 @@ } } -// resolveOwnedRepo resolves a repo AT-URI to DID and checks owner auth -func (x *Xrpc) resolveOwnedRepo(ctx context.Context, actorDid syntax.DID, repoAtUri string) (syntax.DID, xrpcerr.XrpcError, bool) { - repoAt, err := syntax.ParseATURI(repoAtUri) +func parseOptionalDID(field string, value *string) (syntax.DID, error) { + if value == nil || *value == "" { + return "", nil + } + did, err := syntax.ParseDID(*value) if err != nil { - return "", xrpcerr.InvalidRepoError(repoAtUri), false + return "", fmt.Errorf("invalid %s DID %q: %w", field, *value, err) } + return did, nil +} - ident, err := x.Resolver.ResolveIdent(ctx, repoAt.Authority().String()) - if err != nil || ident.Handle.IsInvalidHandle() { - return "", xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)), false +func ciTriggerPairsToPipelinePairs(inputs []*tangled.CiTrigger_Pair) []*tangled.Pipeline_Pair { + if len(inputs) == 0 { + return nil } - - xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} - resp, err := atproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) - if err != nil { - return "", xrpcerr.GenericError(err), false + pairs := make([]*tangled.Pipeline_Pair, 0, len(inputs)) + for _, input := range inputs { + if input == nil { + continue + } + pairs = append(pairs, &tangled.Pipeline_Pair{ + Key: input.Key, + Value: input.Value, + }) } + return pairs +} - repoRec, ok := resp.Value.Val.(*tangled.Repo) +// resolveOwnedRepo resolves a repository DID and checks push auth. +func (x *Xrpc) resolveOwnedRepo(ctx context.Context, actorDid syntax.DID, repoDidStr string) (syntax.DID, xrpcerr.XrpcError, bool) { + repoDid, xerr, ok := x.resolveKnownRepoDid(repoDidStr) if !ok { - return "", xrpcerr.RepoNotFoundError, false + return "", xerr, false } - if repoRec.RepoDid == nil || *repoRec.RepoDid == "" { - return "", xrpcerr.GenericError(fmt.Errorf("repo record %s has no repoDid", repoAt)), false - } - repoDid := *repoRec.RepoDid - isPushAllowed, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, repoDid) + isPushAllowed, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, repoDid.String()) if err != nil || !isPushAllowed { return "", xrpcerr.AccessControlError(actorDid.String()), false } - return syntax.DID(repoDid), xrpcerr.XrpcError{}, true + return repoDid, xrpcerr.XrpcError{}, true +} + +func (x *Xrpc) resolveKnownRepoDid(repoDidStr string) (syntax.DID, xrpcerr.XrpcError, bool) { + repoDid, err := syntax.ParseDID(repoDidStr) + if err != nil { + return "", xrpcerr.GenericError(fmt.Errorf("invalid repo DID %q: %w", repoDidStr, err)), false + } + + if _, err := x.Db.GetRepoByDid(repoDid); err != nil { + return "", xrpcerr.RepoNotFoundError, false + } + + return repoDid, xrpcerr.XrpcError{}, true } diff --git a/spindle/xrpc/pipeline_cancel_pipeline.go b/spindle/xrpc/pipeline_cancel_pipeline.go --- a/spindle/xrpc/pipeline_cancel_pipeline.go +++ b/spindle/xrpc/pipeline_cancel_pipeline.go @@ -4,7 +4,6 @@ "encoding/json" "fmt" "net/http" - "strings" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" @@ -26,36 +25,52 @@ return } - var input tangled.CiPipelineCancelPipeline_Input + var input tangled.CiCancelPipeline_Input if err := json.NewDecoder(r.Body).Decode(&input); err != nil { fail(xrpcerr.GenericError(err)) return } - aturi := syntax.ATURI(input.Pipeline) - pipelineId := models.PipelineId{ - Knot: strings.TrimPrefix(aturi.Authority().String(), "did:web:"), - Rkey: aturi.RecordKey().String(), + pipelineTid, err := syntax.ParseTID(input.Pipeline) + if err != nil { + fail(xrpcerr.GenericError(fmt.Errorf("invalid pipeline TID %q: %w", input.Pipeline, err))) + return } - var workflows []string - if len(input.Workflows) > 0 { - workflows = input.Workflows - } else { - // fetch workflows from db if none are specified - p, err := x.Db.GetPipeline(r.Context(), pipelineId.Rkey) - if err != nil { - fail(xrpcerr.GenericError(fmt.Errorf("failed to get pipeline: %w", err))) - return - } + repoDid, xerr, ok := x.resolveOwnedRepo(r.Context(), actorDid, input.Repo) + if !ok { + fail(xerr) + return + } + repo, err := x.Db.GetRepoByDid(repoDid) + if err != nil { + fail(xrpcerr.GenericError(fmt.Errorf("failed to get repo: %w", err))) + return + } + + // the actor is only authorized against input.Repo, so make sure the + // pipeline actually belongs to it before cancelling anything + p, err := x.Db.GetPipeline(r.Context(), pipelineTid.String()) + if err != nil { + fail(xrpcerr.GenericError(fmt.Errorf("failed to get pipeline: %w", err))) + return + } + if p.Repo == nil || *p.Repo != repoDid.String() { + fail(xrpcerr.AccessControlError(actorDid.String())) + return + } + + pipelineId := models.PipelineId{ + Knot: repo.Knot, + Rkey: pipelineTid.String(), + } + + workflows := input.Workflows + if len(workflows) == 0 { + // cancel every workflow when none are specified for _, w := range p.Workflows { workflows = append(workflows, w.Name) } - } - - if _, xerr, ok := x.resolveOwnedRepo(r.Context(), actorDid, input.Repo); !ok { - fail(xerr) - return } for _, wName := range workflows { diff --git a/spindle/xrpc/xrpc.go b/spindle/xrpc/xrpc.go --- a/spindle/xrpc/xrpc.go +++ b/spindle/xrpc/xrpc.go @@ -25,16 +25,19 @@ const ActorDid = serviceauth.ActorDid -// ErrNoMatchingWorkflows is returned when a manual dispatch resolves to no -// workflows to run: the repo defines none at the requested commit, or none of -// the requested workflow names exist. var ErrNoMatchingWorkflows = errors.New("no workflows to run") -// PipelineTrigger builds and enqueues a manually-dispatched pipeline. It is -// implemented by *spindle.Spindle, which owns the queue and engines; the xrpc -// handler only does auth and input validation before delegating here. +// this is to break an import cycle. spindle imports this package for Xrpc, +// so this package can't import *spindle.Spindle back. type PipelineTrigger interface { - TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string) (syntax.ATURI, error) + TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull PullContext, inputs []*tangled.Pipeline_Pair) (syntax.ATURI, error) +} + +type PullContext struct { + IsPullRequest bool + Pull syntax.ATURI + SourceBranch string + TargetBranch string } type Xrpc struct { diff --git a/xrpc/xrpcclient/xrpc.go b/xrpc/xrpcclient/xrpc.go new file mode 100644 --- /dev/null +++ b/xrpc/xrpcclient/xrpc.go @@ -0,0 +1,39 @@ +package xrpcclient + +import ( + "errors" + "net/http" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" +) + +var ( + ErrXrpcUnsupported = errors.New("xrpc not supported on this knot") + ErrXrpcUnauthorized = errors.New("unauthorized xrpc request") + ErrXrpcForbidden = errors.New("forbidden xrpc request") + ErrXrpcFailed = errors.New("xrpc request failed") + ErrXrpcInvalid = errors.New("invalid xrpc request") +) + +// produces a more manageable error +func HandleXrpcErr(err error) error { + if err == nil { + return nil + } + + var xrpcerr *indigoxrpc.Error + if ok := errors.As(err, &xrpcerr); !ok { + return ErrXrpcInvalid + } + + switch xrpcerr.StatusCode { + case http.StatusNotFound: + return ErrXrpcUnsupported + case http.StatusUnauthorized: + return ErrXrpcUnauthorized + case http.StatusForbidden: + return ErrXrpcForbidden + default: + return ErrXrpcFailed + } +} diff --git a/appview/pages/templates/repo/pipelines/pipelines.html b/appview/pages/templates/repo/pipelines/pipelines.html --- a/appview/pages/templates/repo/pipelines/pipelines.html +++ b/appview/pages/templates/repo/pipelines/pipelines.html @@ -97,6 +97,8 @@
{{ $target := .Trigger.TargetRef }} {{ $workflows := .Workflows }} + {{ $pullPath := "" }} + {{ if .Trigger.IsPullRequest }}{{ $pullPath = pipelinePullPath . }}{{ end }} @@ -115,13 +117,16 @@ Pull request {{ $target }} {{ i "arrow-left" "size-3 text-gray-500 dark:text-gray-400" }} - {{ .Trigger.PRSourceBranch }} + {{ pipelineSourceLabel . }} {{ else if .Trigger.IsManual }} {{ i "circle-play" "size-4 text-gray-500 dark:text-gray-400 shrink-0" }} Manual dispatch {{ end }} {{ if .IsResponding }} + {{ end }} + {{ if $pullPath }} + (view PR) {{ end }}
@@ -130,7 +135,7 @@
{{ if .Sha }} - + {{ slice .Sha 0 8 }} {{ end }} diff --git a/appview/pages/templates/repo/pipelines/workflow.html b/appview/pages/templates/repo/pipelines/workflow.html --- a/appview/pages/templates/repo/pipelines/workflow.html +++ b/appview/pages/templates/repo/pipelines/workflow.html @@ -12,8 +12,7 @@ {{ block "sidebar" . }} {{ end }}
- - {{ if $.RepoInfo.Roles.IsOwner }} + {{ if $.RepoInfo.Roles.IsPushAllowed }} {{ $status := (index .Pipeline.Statuses .Workflow).Latest.Status }}
- {{ range $name, $all := .Statuses }} + {{ range $name, $all := $pipeline.Statuses }}
@@ -550,7 +549,8 @@ {{ end }}
- {{ end }} + {{ else if and $root.Pull.IsForkBased (eq $item.RoundNumber $root.Pull.LastRoundNumber) $root.RepoInfo.Roles.IsOwner }} + {{ template "repo/pulls/fragments/triggerCi" $root }} {{ end }} {{ end }} diff --git a/appview/pages/templates/repo/pulls/fragments/triggerCi.html b/appview/pages/templates/repo/pulls/fragments/triggerCi.html new file mode 100644 --- /dev/null +++ b/appview/pages/templates/repo/pulls/fragments/triggerCi.html @@ -0,0 +1,49 @@ +{{ define "repo/pulls/fragments/runCiButton" }} + +{{ end }} + +{{ define "repo/pulls/fragments/triggerCi" }} +{{ if .WorkflowsChanged }} +
+ +
+ {{ i "triangle-alert" "w-4 h-4 flex-shrink-0" }} + Workflow files changed in this round +
+ Expand + +
+
+
+
    + {{ range .ChangedWorkflowFiles }} +
  • + {{ i "file-warning" "inline-flex w-4 h-4 mr-1.5 text-amber-600 dark:text-amber-500 flex-shrink-0" }} + {{ . }} +
  • + {{ end }} +
+
+ {{ $confirm := printf "Workflow files changed in this round (%s). Review before running. Run anyway?" (join .ChangedWorkflowFiles ", ") }} + {{ template "repo/pulls/fragments/runCiButton" (dict "RepoInfo" .RepoInfo "Pull" .Pull "Confirm" $confirm) }} +
+
+{{ else }} +
+ {{ i "circle-play" "w-4 h-4 flex-shrink-0" }} + CI hasn't run on the latest commit + {{ template "repo/pulls/fragments/runCiButton" (dict "RepoInfo" .RepoInfo "Pull" .Pull "Confirm" "") }} +
+{{ end }} +
+{{ end }} -- tangled.sh