From b42612699691bc0134bfbea39506949d259d6d11 Mon Sep 17 00:00:00 2001 From: dawn Date: Wed, 1 Jul 2026 20:00:12 +0300 Subject: [PATCH] appview,spindle,lexicons: support triggering ci for fork-based pull requests Signed-off-by: dawn --- api/tangled/cbor_gen.go | 727 ++++++++++++++++-- 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/ingester.go | 2 +- appview/ingester_repo.go | 2 +- appview/ingester_repo_test.go | 2 +- appview/knots/knots.go | 2 +- appview/oauth/oauth.go | 15 + appview/pages/funcmap.go | 57 +- appview/pages/pages.go | 6 + .../templates/repo/pipelines/pipelines.html | 9 +- .../templates/repo/pipelines/workflow.html | 3 +- .../repo/pulls/fragments/triggerCi.html | 49 ++ appview/pages/templates/repo/pulls/pull.html | 10 +- 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/serververify/verify.go | 2 +- appview/spindles/spindles.go | 2 +- appview/state/state.go | 4 +- cmd/cborgen/cborgen.go | 1 + flake.nix | 2 +- lexicons/ci/pipeline.json | 5 + lexicons/ci/trigger.json | 51 +- lexicons/ci/triggerPipeline.json | 24 +- lexicons/pipeline/pipeline.json | 14 +- {appview/repoverify => repoverify}/verify.go | 6 +- .../repoverify => repoverify}/verify_test.go | 0 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/server.go | 152 +++- spindle/tapclient.go | 8 +- spindle/xrpc/ci_pipeline_trigger_pipeline.go | 139 +++- spindle/xrpc/pipeline_cancel_pipeline.go | 55 +- spindle/xrpc/xrpc.go | 17 +- types/pipeline.go | 27 +- {appview => xrpc}/xrpcclient/xrpc.go | 0 59 files changed, 1650 insertions(+), 323 deletions(-) create mode 100644 appview/pages/templates/repo/pulls/fragments/triggerCi.html create mode 100644 appview/pulls/trigger_ci.go rename {appview/repoverify => repoverify}/verify.go (95%) rename {appview/repoverify => repoverify}/verify_test.go (100%) rename {appview => xrpc}/xrpcclient/xrpc.go (100%) diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index 0335dbc8..36312457 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -669,7 +669,7 @@ func (t *CiPipeline) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 6 + fieldCount := 7 if t.CreatedAt == nil { fieldCount-- @@ -679,6 +679,10 @@ func (t *CiPipeline) MarshalCBOR(w io.Writer) error { fieldCount-- } + if t.SourceRepo == nil { + fieldCount-- + } + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { return err } @@ -834,6 +838,38 @@ func (t *CiPipeline) MarshalCBOR(w io.Writer) error { } } + + // 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 @@ func (t *CiPipeline) UnmarshalCBOR(r io.Reader) (err error) { 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 { @@ -1011,6 +1047,27 @@ func (t *CiPipeline) UnmarshalCBOR(r io.Reader) (err error) { } } + // 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 @@ -2174,7 +2231,334 @@ func (t *CiSubscribePipelineLogs_Data) UnmarshalCBOR(r io.Reader) (err error) { return nil } -func (t *CiTrigger_Manual) MarshalCBOR(w io.Writer) error { +func (t *CiTrigger_Manual) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 5 + + 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 + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.ci.trigger#manual"))); err != nil { + return err + } + 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 +} + +func (t *CiTrigger_Manual) UnmarshalCBOR(r io.Reader) (err error) { + *t = CiTrigger_Manual{} + + 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_Manual: map struct too large (%d)", extra) + } + + 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 @@ -2182,33 +2566,60 @@ func (t *CiTrigger_Manual) MarshalCBOR(w io.Writer) error { cw := cbg.NewCborWriter(w) - if _, err := cw.Write([]byte{161}); err != nil { + if _, err := cw.Write([]byte{162}); err != nil { return err } - // t.LexiconTypeID (string) (string) - if len("$type") > 1000000 { - return xerrors.Errorf("Value in field \"$type\" was too long") + // 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("$type"))); err != nil { + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("key"))); err != nil { return err } - if _, err := cw.WriteString(string("$type")); err != nil { + if _, err := cw.WriteString(string("key")); err != nil { return err } - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.ci.trigger#manual"))); err != nil { + 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("sh.tangled.ci.trigger#manual")); err != nil { + 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_Manual) UnmarshalCBOR(r io.Reader) (err error) { - *t = CiTrigger_Manual{} +func (t *CiTrigger_Pair) UnmarshalCBOR(r io.Reader) (err error) { + *t = CiTrigger_Pair{} cr := cbg.NewCborReader(r) @@ -2227,7 +2638,7 @@ func (t *CiTrigger_Manual) UnmarshalCBOR(r io.Reader) (err error) { } if extra > cbg.MaxLength { - return fmt.Errorf("CiTrigger_Manual: map struct too large (%d)", extra) + return fmt.Errorf("CiTrigger_Pair: map struct too large (%d)", extra) } n := extra @@ -2248,8 +2659,8 @@ func (t *CiTrigger_Manual) UnmarshalCBOR(r io.Reader) (err error) { } 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 @@ func (t *CiTrigger_Manual) UnmarshalCBOR(r io.Reader) (err error) { 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,16 +2699,56 @@ func (t *CiTrigger_PullRequest) MarshalCBOR(w io.Writer) error { } 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) if len("$type") > 1000000 { return xerrors.Errorf("Value in field \"$type\" was too long") @@ -2306,29 +2768,6 @@ func (t *CiTrigger_PullRequest) MarshalCBOR(w io.Writer) error { 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 - } - // t.SourceSha (string) (string) if len("sourceSha") > 1000000 { return xerrors.Errorf("Value in field \"sourceSha\" was too long") @@ -2352,6 +2791,38 @@ func (t *CiTrigger_PullRequest) MarshalCBOR(w io.Writer) error { 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) if t.SourceBranch != nil { @@ -2450,19 +2921,29 @@ func (t *CiTrigger_PullRequest) UnmarshalCBOR(r io.Reader) (err error) { } switch string(nameBuf[:nameLen]) { - // t.LexiconTypeID (string) (string) - case "$type": + // 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.LexiconTypeID = string(sval) + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Pull = (*string)(&sval) + } } - // t.Action (string) (string) - case "action": + // t.LexiconTypeID (string) (string) + case "$type": { sval, err := cbg.ReadStringWithMax(cr, 1000000) @@ -2470,7 +2951,7 @@ func (t *CiTrigger_PullRequest) UnmarshalCBOR(r io.Reader) (err error) { return err } - t.Action = string(sval) + t.LexiconTypeID = string(sval) } // t.SourceSha (string) (string) case "sourceSha": @@ -2483,6 +2964,27 @@ func (t *CiTrigger_PullRequest) UnmarshalCBOR(r io.Reader) (err error) { 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 @@ func (t *Pipeline_PullRequestTriggerData) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) + fieldCount := 4 - if _, err := cw.Write([]byte{164}); err != nil { - return err - } - - // t.Action (string) (string) - if len("action") > 1000000 { - return xerrors.Errorf("Value in field \"action\" was too long") + if t.Pull == nil { + fieldCount-- } - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("action"))); err != nil { - return err - } - if _, err := cw.WriteString(string("action")); err != nil { + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { return err } - if len(t.Action) > 1000000 { - return xerrors.Errorf("Value in field t.Action was too long") - } + // t.Pull (string) (string) + if t.Pull != nil { - 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 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.SourceSha (string) (string) @@ -8025,16 +8541,26 @@ func (t *Pipeline_PullRequestTriggerData) UnmarshalCBOR(r io.Reader) (err error) } 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 + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } - t.Action = string(sval) + t.Pull = (*string)(&sval) + } } // t.SourceSha (string) (string) case "sourceSha": @@ -8617,7 +9143,7 @@ func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 5 + fieldCount := 6 if t.Manual == nil { fieldCount-- @@ -8631,6 +9157,10 @@ func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { fieldCount-- } + if t.SourceRepo == nil { + fieldCount-- + } + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { return err } @@ -8712,6 +9242,38 @@ func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { } } + // 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.PullRequest (tangled.Pipeline_PullRequestTriggerData) (struct) if t.PullRequest != nil { @@ -8845,6 +9407,27 @@ func (t *Pipeline_TriggerMetadata) UnmarshalCBOR(r io.Reader) (err error) { } } + // 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 index 4368e1bb..290917ad 100644 --- a/api/tangled/cipipeline.go +++ b/api/tangled/cipipeline.go @@ -26,6 +26,8 @@ type CiPipeline struct { 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 index f75c2723..3229d527 100644 --- a/api/tangled/citrigger.go +++ b/api/tangled/citrigger.go @@ -10,7 +10,20 @@ const () // // 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 @@ type CiTrigger_Manual struct { // // 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 index d372886a..364672bf 100644 --- a/api/tangled/citriggerPipeline.go +++ b/api/tangled/citriggerPipeline.go @@ -6,6 +6,8 @@ package tangled import ( "context" + "encoding/json" + "fmt" "github.com/bluesky-social/indigo/lex/util" ) @@ -16,16 +18,50 @@ const ( // 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. type CiTriggerPipeline_Output struct { // pipeline: AT-URI of the created pipeline diff --git a/api/tangled/pipelinecancelPipeline.go b/api/tangled/pipelinecancelPipeline.go index 584bf77d..975550d0 100644 --- 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 @@ import ( ) 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 index b51b1127..da00f406 100644 --- a/api/tangled/tangledpipeline.go +++ b/api/tangled/tangledpipeline.go @@ -49,10 +49,11 @@ type Pipeline_Pair struct { // 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 @@ type Pipeline_TriggerMetadata struct { 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/ingester.go b/appview/ingester.go index dc3c648d..65bbabd0 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -31,12 +31,12 @@ import ( "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 index 95f7ed0a..953bf1f3 100644 --- a/appview/ingester_repo.go +++ b/appview/ingester_repo.go @@ -15,8 +15,8 @@ import ( "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 index a8eb452b..44302d81 100644 --- a/appview/ingester_repo_test.go +++ b/appview/ingester_repo_test.go @@ -16,9 +16,9 @@ import ( "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/appview/knots/knots.go b/appview/knots/knots.go index 7b920b93..8e574a06 100644 --- a/appview/knots/knots.go +++ b/appview/knots/knots.go @@ -20,13 +20,13 @@ import ( "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 index e9953cba..16eeb82d 100644 --- a/appview/oauth/oauth.go +++ b/appview/oauth/oauth.go @@ -22,6 +22,7 @@ import ( "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" @@ -445,6 +446,20 @@ func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Cl }, 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) { parsedDid, err := syntax.ParseDID(did) if err != nil { diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go index f43e6e43..953d0409 100644 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -35,12 +35,27 @@ import ( "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{ "split": func(s string) []string { @@ -91,16 +106,42 @@ func (p *Pages) funcMap() template.FuncMap { } 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 + } + 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 repo.RepoIdentifier() + 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 index f99c12f4..5ec8513f 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1442,6 +1442,12 @@ type RepoSinglePullParams struct { 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/pages/templates/repo/pipelines/pipelines.html b/appview/pages/templates/repo/pipelines/pipelines.html index e26438fd..04f86600 100644 --- 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,7 +117,7 @@ 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 @@ -123,6 +125,9 @@ {{ 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 index c8e8a30b..3325b5ff 100644 --- 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 }}
+{{ 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 }} diff --git a/appview/pages/templates/repo/pulls/pull.html b/appview/pages/templates/repo/pulls/pull.html index 77b94bdd..ccbe7fac 100644 --- a/appview/pages/templates/repo/pulls/pull.html +++ b/appview/pages/templates/repo/pulls/pull.html @@ -514,9 +514,8 @@ {{ $item := index . 0 }} {{ $root := index . 3 }} {{ $pipeline := index $root.Pipelines $item.SourceRev }} - {{ with $pipeline }} - {{ $id := .Id }} - {{ if .Statuses }} + {{ if and $pipeline $pipeline.Statuses }} + {{ $id := $pipeline.Id }}
{{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" false) }} @@ -526,7 +525,7 @@
- {{ 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/pipelines/pipelines.go b/appview/pipelines/pipelines.go index 48fa2619..7e69262d 100644 --- a/appview/pipelines/pipelines.go +++ b/appview/pipelines/pipelines.go @@ -46,7 +46,7 @@ func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler { 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) @@ -251,6 +251,50 @@ func (w *webLogScheduler) AddWork(ctx context.Context, _ string, val *tangled.Ci // 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 @@ func (p *Pipelines) CancelWorkflow(w http.ResponseWriter, r *http.Request) { 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 @@ func (p *Pipelines) retry(w http.ResponseWriter, r *http.Request, only string) { } 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 @@ func (p *Pipelines) retry(w http.ResponseWriter, r *http.Request, only string) { r.Context(), spindleClient, &tangled.CiTriggerPipeline_Input{ - Repo: string(f.RepoAt()), - Sha: orig.Commit, + Repo: f.RepoDid, + Trigger: retryPipelineTrigger(orig), Workflows: workflows, }, ) @@ -609,18 +652,3 @@ func (p *Pipelines) retry(w http.ResponseWriter, r *http.Request, only string) { } 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 index aa3d05e3..6b2936e4 100644 --- a/appview/pulls/compose.go +++ b/appview/pulls/compose.go @@ -18,9 +18,9 @@ import ( "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 index 34f37a83..2767f663 100644 --- a/appview/pulls/create.go +++ b/appview/pulls/create.go @@ -16,11 +16,11 @@ import ( "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 index de2b41fe..1b0bba83 100644 --- a/appview/pulls/merge.go +++ b/appview/pulls/merge.go @@ -10,8 +10,8 @@ import ( "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 index 6309ea33..5fb6fbd7 100644 --- a/appview/pulls/resubmit.go +++ b/appview/pulls/resubmit.go @@ -13,11 +13,11 @@ import ( "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 index 530d4941..03a43909 100644 --- a/appview/pulls/router.go +++ b/appview/pulls/router.go @@ -50,6 +50,11 @@ func (s *Pulls) Router(mw *middleware.Middleware) http.Handler { 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 index 4c1427c8..40b5f451 100644 --- a/appview/pulls/single.go +++ b/appview/pulls/single.go @@ -10,10 +10,10 @@ import ( "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 @@ func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff 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 { @@ -274,6 +284,9 @@ func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff 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 index 00000000..61dccf79 --- /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 index 2a8c6559..1dd39eb5 100644 --- a/appview/repo/artifact.go +++ b/appview/repo/artifact.go @@ -13,11 +13,11 @@ import ( "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 index f9596802..d1bd951e 100644 --- a/appview/repo/blob.go +++ b/appview/repo/blob.go @@ -18,8 +18,8 @@ import ( "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 index 1a6ed938..f6705915 100644 --- a/appview/repo/branches.go +++ b/appview/repo/branches.go @@ -8,8 +8,8 @@ import ( "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 index 5ba2af80..5b6442e0 100644 --- a/appview/repo/compare.go +++ b/appview/repo/compare.go @@ -10,9 +10,9 @@ import ( "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 index 9971b57f..22f4cec2 100644 --- a/appview/repo/log.go +++ b/appview/repo/log.go @@ -12,8 +12,8 @@ import ( "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 index 996010b9..7ecae68b 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -27,7 +27,6 @@ import ( "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 @@ import ( "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 index bc41fc4b..2bdae1c4 100644 --- a/appview/repo/settings.go +++ b/appview/repo/settings.go @@ -20,11 +20,11 @@ import ( "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 index 66d3752e..ae751ae9 100644 --- a/appview/repo/tags.go +++ b/appview/repo/tags.go @@ -11,9 +11,9 @@ import ( "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 index f72001d8..f54fbbda 100644 --- a/appview/repo/tree.go +++ b/appview/repo/tree.go @@ -13,8 +13,8 @@ import ( "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/serververify/verify.go b/appview/serververify/verify.go index 27b09e10..bdd90e8d 100644 --- a/appview/serververify/verify.go +++ b/appview/serververify/verify.go @@ -8,9 +8,9 @@ import ( 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 index f862aa51..6840fb39 100644 --- a/appview/spindles/spindles.go +++ b/appview/spindles/spindles.go @@ -18,11 +18,11 @@ import ( "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 index e79412bd..b30fb464 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -33,8 +33,6 @@ import ( "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 @@ import ( 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/cmd/cborgen/cborgen.go b/cmd/cborgen/cborgen.go index 50eceb01..36d45f61 100644 --- a/cmd/cborgen/cborgen.go +++ b/cmd/cborgen/cborgen.go @@ -21,6 +21,7 @@ func main() { tangled.CiSubscribePipelineLogs_Control{}, tangled.CiSubscribePipelineLogs_Data{}, tangled.CiTrigger_Manual{}, + tangled.CiTrigger_Pair{}, tangled.CiTrigger_PullRequest{}, tangled.CiTrigger_Push{}, tangled.FeedComment{}, diff --git a/flake.nix b/flake.nix index 4cee0bb9..834e529a 100644 --- a/flake.nix +++ b/flake.nix @@ -524,7 +524,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/lexicons/ci/pipeline.json b/lexicons/ci/pipeline.json index ef89839f..33e81cc4 100644 --- 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 index f621b65b..15c49e1d 100644 --- 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 index 2abe12b6..e45fdafa 100644 --- 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" - }, - "sha": { - "type": "string", - "minLength": 40, - "maxLength": 40, - "description": "commit SHA to run the pipeline at" + "format": "did", + "description": "Target repository DID. Auth is checked against this repo." }, - "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 index 5a6e4954..9deb76fd 100644 --- 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/appview/repoverify/verify.go b/repoverify/verify.go similarity index 95% rename from appview/repoverify/verify.go rename to repoverify/verify.go index 8fe87a8d..77b2f034 100644 --- a/appview/repoverify/verify.go +++ b/repoverify/verify.go @@ -13,8 +13,8 @@ import ( "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" + "tangled.org/core/xrpc/xrpcclient" ) type RepoDid syntax.DID @@ -68,6 +68,9 @@ 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) @@ -129,6 +132,7 @@ func resolveAndDescribe( RepoDid: repoDid, OwnerDid: ownerDid, KnotURL: knot, + Rkey: out.Rkey, }, nil } diff --git a/appview/repoverify/verify_test.go b/repoverify/verify_test.go similarity index 100% rename from appview/repoverify/verify_test.go rename to repoverify/verify_test.go diff --git a/spindle/db/pipelines.go b/spindle/db/pipelines.go index d2f4556b..cccaba9f 100644 --- a/spindle/db/pipelines.go +++ b/spindle/db/pipelines.go @@ -9,6 +9,7 @@ import ( "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 @@ func (d *DB) QueryPipelines(ctx context.Context, repoDid string, commits []strin } var query string - var args []interface{} + var args []any query = ` select rkey, event, created from events @@ -33,7 +34,11 @@ func (d *DB) QueryPipelines(ctx context.Context, repoDid string, commits []strin 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 @@ func (d *DB) QueryPipelines(ctx context.Context, repoDid string, commits []strin 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 @@ func (d *DB) GetPipeline(ctx context.Context, rkey string) (*tangled.CiPipeline, 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 @@ func (d *DB) mapToCiPipeline(ctx context.Context, rkey string, created int64, ra 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 @@ func (d *DB) mapToCiPipeline(ctx context.Context, rkey string, created int64, ra 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,16 +203,39 @@ func (d *DB) mapToCiPipeline(ctx context.Context, rkey string, created int64, ra }) } + 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) { pipelineAtUri := workflowId.PipelineId.AtUri() diff --git a/spindle/engine/engine.go b/spindle/engine/engine.go index b83fdae6..6589f9d1 100644 --- a/spindle/engine/engine.go +++ b/spindle/engine/engine.go @@ -27,12 +27,14 @@ type workflowFinalizer interface { 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 @@ func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, d 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 @@ func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, d 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 index 9d1328f0..d9d54389 100644 --- a/spindle/models/clone.go +++ b/spindle/models/clone.go @@ -111,16 +111,14 @@ func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) { // 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 index 82b676ec..a5feb8c6 100644 --- a/spindle/models/clone_test.go +++ b/spindle/models/clone_test.go @@ -83,7 +83,6 @@ func TestBuildCloneStep_PullRequestTrigger(t *testing.T) { SourceSha: "pr-sha-789", SourceBranch: "feature-branch", TargetBranch: "main", - Action: "opened", }, Repo: &tangled.Pipeline_TriggerRepo{ Knot: "example.com", @@ -101,6 +100,41 @@ func TestBuildCloneStep_PullRequestTrigger(t *testing.T) { } } +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") + } +} + func TestBuildCloneStep_ManualTrigger(t *testing.T) { twf := tangled.Pipeline_Workflow{ Clone: &tangled.Pipeline_CloneOpts{ diff --git a/spindle/models/pipeline.go b/spindle/models/pipeline.go index fec501dc..a794644b 100644 --- a/spindle/models/pipeline.go +++ b/spindle/models/pipeline.go @@ -5,6 +5,8 @@ import "github.com/bluesky-social/indigo/atproto/syntax" 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 index e0226aa3..23534395 100644 --- a/spindle/models/pipeline_env.go +++ b/spindle/models/pipeline_env.go @@ -10,6 +10,10 @@ import ( // 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 @@ func PipelineEnvVars(tr *tangled.Pipeline_TriggerMetadata, pipelineId PipelineId 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 @@ func PipelineEnvVars(tr *tangled.Pipeline_TriggerMetadata, pipelineId PipelineId 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 index cbedd769..54fc5f5e 100644 --- a/spindle/models/pipeline_env_test.go +++ b/spindle/models/pipeline_env_test.go @@ -110,7 +110,6 @@ func TestPipelineEnvVars_PullRequest(t *testing.T) { SourceBranch: "feature-branch", TargetBranch: "main", SourceSha: "pr-sha-789", - Action: "opened", }, Repo: &tangled.Pipeline_TriggerRepo{ Knot: "example.com", @@ -145,6 +144,12 @@ func TestPipelineEnvVars_PullRequest(t *testing.T) { } // 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 @@ func TestPipelineEnvVars_PullRequest(t *testing.T) { 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/server.go b/spindle/server.go index ad6eda41..e727ebf6 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -28,6 +28,7 @@ import ( "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 @@ type Spindle struct { cfg *config.Config ks *eventconsumer.Consumer res *idresolver.Resolver + verify repoverify.Verifier vault secrets.Manager motd []byte motdMu sync.RWMutex @@ -156,6 +158,7 @@ func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]m jq: jq, cfg: cfg, res: resolver, + verify: repoverify.New(resolver, cfg.Server.Dev), vault: vault, motd: defaultMotd, rootCtx: ctx, @@ -447,7 +450,7 @@ func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Sourc 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 @@ func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Sourc // 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 + } +} + +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) } -// 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) { +// 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 @@ func (s *Spindle) runPipeline(ctx context.Context, repoDid syntax.DID, trigger t 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 @@ func filterWorkflows(workflows []*tangled.Pipeline_Workflow, only []string) []*t 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 @@ func (s *Spindle) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, re 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 @@ func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev strin 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 @@ func (s *Spindle) processPipeline(repoDid syntax.DID, tpl tangled.Pipeline, pipe 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 @@ func (s *Spindle) processPipeline(repoDid syntax.DID, tpl tangled.Pipeline, pipe 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 index 2a313895..6060d2ca 100644 --- a/spindle/tapclient.go +++ b/spindle/tapclient.go @@ -383,7 +383,6 @@ func (t *Tap) processPull(ctx context.Context, evt *tapc.RecordEventData) error 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 @@ func (t *Tap) processPull(ctx context.Context, evt *tapc.RecordEventData) error 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/spindle/xrpc/ci_pipeline_trigger_pipeline.go b/spindle/xrpc/ci_pipeline_trigger_pipeline.go index 669c3180..c31f4d59 100644 --- a/spindle/xrpc/ci_pipeline_trigger_pipeline.go +++ b/spindle/xrpc/ci_pipeline_trigger_pipeline.go @@ -7,9 +7,7 @@ import ( "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 @@ func (x *Xrpc) TriggerPipeline(w http.ResponseWriter, r *http.Request) { 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 @@ func (x *Xrpc) TriggerPipeline(w http.ResponseWriter, r *http.Request) { } } -// 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 index f74ef35d..0238c946 100644 --- a/spindle/xrpc/pipeline_cancel_pipeline.go +++ b/spindle/xrpc/pipeline_cancel_pipeline.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" @@ -26,38 +25,54 @@ func (x *Xrpc) CancelPipeline(w http.ResponseWriter, r *http.Request) { 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) + pipelineTid, err := syntax.ParseTID(input.Pipeline) + if err != nil { + fail(xrpcerr.GenericError(fmt.Errorf("invalid pipeline TID %q: %w", input.Pipeline, 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: strings.TrimPrefix(aturi.Authority().String(), "did:web:"), - Rkey: aturi.RecordKey().String(), + Knot: repo.Knot, + Rkey: pipelineTid.String(), } - 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 - } + 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 { wid := models.WorkflowId{ PipelineId: pipelineId, diff --git a/spindle/xrpc/xrpc.go b/spindle/xrpc/xrpc.go index 11bcff93..d8232be4 100644 --- a/spindle/xrpc/xrpc.go +++ b/spindle/xrpc/xrpc.go @@ -25,16 +25,19 @@ import ( 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/types/pipeline.go b/types/pipeline.go index 68e0cfb3..3f9ecdf6 100644 --- a/types/pipeline.go +++ b/types/pipeline.go @@ -106,11 +106,11 @@ func (t Trigger) TargetRef() string { } 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 } @@ -131,6 +131,17 @@ func (t Trigger) PRSourceBranch() string { 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 { *tangled.CiPipeline } @@ -242,6 +253,14 @@ func (p Pipeline) Sha() string { 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 { var ws []string if p.CiPipeline != nil { diff --git a/appview/xrpcclient/xrpc.go b/xrpc/xrpcclient/xrpc.go similarity index 100% rename from appview/xrpcclient/xrpc.go rename to xrpc/xrpcclient/xrpc.go -- 2.51.2