diff --git a/internal/entities/properties/constants.go b/internal/entities/properties/constants.go index 43dc61c276..3005b76b5e 100644 --- a/internal/entities/properties/constants.go +++ b/internal/entities/properties/constants.go @@ -44,3 +44,13 @@ const ( // ArtifactPropertyType represents the type of the artifact (e.g 'container') ArtifactPropertyType = "type" ) + +// Release property keys +const ( + // ReleasePropertyTag represents the release tag name. + ReleasePropertyTag = "tag" + // ReleasePropertyBranch represents the release branch + ReleasePropertyBranch = "branch" + // ReleaseCommitSHA represents the commit SHA of the release + ReleaseCommitSHA = "commit_sha" +) diff --git a/internal/providers/github/entities.go b/internal/providers/github/entities.go index 8be40fec26..405ca72c28 100644 --- a/internal/providers/github/entities.go +++ b/internal/providers/github/entities.go @@ -247,6 +247,8 @@ func (c *GitHub) PropertiesToProtoMessage( return ghprop.ArtifactV1FromProperties(props) case minderv1.Entity_ENTITY_PULL_REQUESTS: return ghprop.PullRequestV1FromProperties(props) + case minderv1.Entity_ENTITY_RELEASE: + return ghprop.EntityInstanceV1FromReleaseProperties(props) } return nil, fmt.Errorf("conversion of entity type %s is not handled by the github provider", entType) diff --git a/internal/providers/github/properties/fetcher.go b/internal/providers/github/properties/fetcher.go index 169c2a84e4..c6c55a9a50 100644 --- a/internal/providers/github/properties/fetcher.go +++ b/internal/providers/github/properties/fetcher.go @@ -52,6 +52,8 @@ func (_ ghEntityFetcher) EntityPropertyFetcher(entType minderv1.Entity) GhProper return NewRepositoryFetcher() case minderv1.Entity_ENTITY_ARTIFACTS: return NewArtifactFetcher() + case minderv1.Entity_ENTITY_RELEASE: + return NewReleaseFetcher() } return nil diff --git a/internal/providers/github/properties/release.go b/internal/providers/github/properties/release.go new file mode 100644 index 0000000000..42486f7d43 --- /dev/null +++ b/internal/providers/github/properties/release.go @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright 2024 The Minder Authors +// SPDX-License-Identifier: Apache-2.0 + +package properties + +import ( + "context" + "fmt" + "net/http" + + go_github "github.com/google/go-github/v63/github" + + "github.com/mindersec/minder/internal/entities/properties" + minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" + v1 "github.com/mindersec/minder/pkg/providers/v1" +) + +// Release Properties +const ( + // ReleasePropertyOwner represents the github owner + ReleasePropertyOwner = "github/owner" + // ReleasePropertyRepo represents the github repo + ReleasePropertyRepo = "github/repo" +) + +// ReleaseFetcher is a property fetcher for releases +type ReleaseFetcher struct { + propertyFetcherBase +} + +// NewReleaseFetcher creates a new ReleaseFetcher +func NewReleaseFetcher() *ReleaseFetcher { + return &ReleaseFetcher{ + propertyFetcherBase: propertyFetcherBase{ + propertyOrigins: []propertyOrigin{ + { + keys: []string{ + // general entity + properties.PropertyName, + properties.PropertyUpstreamID, + // general release + properties.ReleasePropertyTag, + properties.ReleasePropertyBranch, + ReleasePropertyOwner, + ReleasePropertyRepo, + }, + wrapper: getReleaseWrapper, + }, + }, + operationalProperties: []string{}, + }, + } +} + +// GetName returns the name of the release +func (_ *ReleaseFetcher) GetName(props *properties.Properties) (string, error) { + owner := props.GetProperty(ReleasePropertyOwner).GetString() + repo, err := props.GetProperty(ReleasePropertyRepo).AsString() + if err != nil { + return "", fmt.Errorf("failed to get repo name: %w", err) + } + + tag, err := props.GetProperty(properties.ReleasePropertyTag).AsString() + if err != nil { + return "", fmt.Errorf("failed to get tag name: %w", err) + } + + return getReleaseNameFromParams(owner, repo, tag), nil +} + +func getReleaseNameFromParams(owner, repo, tag string) string { + if owner == "" { + return fmt.Sprintf("%s/%s", repo, tag) + } + + return fmt.Sprintf("%s/%s/%s", owner, repo, tag) +} + +func getReleaseWrapper( + ctx context.Context, ghCli *go_github.Client, _ bool, getByProps *properties.Properties, +) (map[string]any, error) { + upstreamID, err := getByProps.GetProperty(properties.PropertyUpstreamID).AsInt64() + if err != nil { + return nil, fmt.Errorf("upstream ID not found or invalid: %w", err) + } + + owner, err := getByProps.GetProperty(ReleasePropertyOwner).AsString() + if err != nil { + return nil, fmt.Errorf("owner not found or invalid: %w", err) + } + + repo, err := getByProps.GetProperty(ReleasePropertyRepo).AsString() + if err != nil { + return nil, fmt.Errorf("repo not found or invalid: %w", err) + } + + var fetchErr error + var release *go_github.RepositoryRelease + var result *go_github.Response + release, result, fetchErr = ghCli.Repositories.GetRelease(ctx, owner, repo, + upstreamID) + if fetchErr != nil { + if result != nil && result.StatusCode == http.StatusNotFound { + return nil, v1.ErrEntityNotFound + } + return nil, fmt.Errorf("failed to fetch release: %w", fetchErr) + } + + branch, commitSha, err := getBranchAndCommit(ctx, owner, repo, release.GetTargetCommitish(), ghCli) + if err != nil { + return nil, fmt.Errorf("failed to get branch and commit SHA: %w", err) + } + + return map[string]any{ + properties.PropertyUpstreamID: properties.NumericalValueToUpstreamID(release.GetID()), + properties.PropertyName: getReleaseNameFromParams(owner, repo, release.GetTagName()), + ReleasePropertyOwner: owner, + ReleasePropertyRepo: repo, + properties.ReleasePropertyTag: release.GetTagName(), + properties.ReleaseCommitSHA: commitSha, + properties.ReleasePropertyBranch: branch, + }, nil +} + +func getBranchAndCommit( + ctx context.Context, + owner string, + repo string, + commitish string, + ghCli *go_github.Client, +) (branch string, commitSha string, err error) { + if commitish == "" { + // We have no info, but this is not an error. We simply don't fill this + // information just yet. We'll get it on entity refresh. + return "", "", nil + } + + // check if the target commitish is a branch + br, res, err := ghCli.Repositories.GetBranch(ctx, owner, repo, commitish, 1) + if err == nil { + return br.GetName(), br.GetCommit().GetSHA(), nil + } + + if res == nil || res.StatusCode != http.StatusNotFound { + return "", "", fmt.Errorf("failed to fetch branch: %w", err) + } + + // The commitish is a commit SHA without a branch + return "", commitish, nil +} + +// EntityInstanceV1FromReleaseProperties creates a new EntityInstance from the given properties +func EntityInstanceV1FromReleaseProperties(props *properties.Properties) (*minderv1.EntityInstance, error) { + _, err := props.GetProperty(properties.PropertyUpstreamID).AsString() + if err != nil { + return nil, fmt.Errorf("upstream ID not found or invalid: %w", err) + } + + tag, err := props.GetProperty(properties.ReleasePropertyTag).AsString() + if err != nil { + return nil, fmt.Errorf("tag not found or invalid: %w", err) + } + + owner := props.GetProperty(ReleasePropertyOwner).GetString() + + repo, err := props.GetProperty(ReleasePropertyRepo).AsString() + if err != nil { + return nil, fmt.Errorf("repo not found or invalid: %w", err) + } + + name := getReleaseNameFromParams(owner, repo, tag) + + return &minderv1.EntityInstance{ + Type: minderv1.Entity_ENTITY_RELEASE, + Name: name, + Properties: props.ToProtoStruct(), + }, nil +} diff --git a/internal/providers/github/webhook/handlers_releases.go b/internal/providers/github/webhook/handlers_releases.go new file mode 100644 index 0000000000..70feec9223 --- /dev/null +++ b/internal/providers/github/webhook/handlers_releases.go @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright 2024 The Minder Authors +// SPDX-License-Identifier: Apache-2.0 + +package webhook + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/mindersec/minder/internal/db" + entityMessage "github.com/mindersec/minder/internal/entities/handlers/message" + "github.com/mindersec/minder/internal/entities/properties" + ghprop "github.com/mindersec/minder/internal/providers/github/properties" + pb "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" + "github.com/mindersec/minder/pkg/eventer/constants" +) + +type releaseEvent struct { + Action *string `json:"action,omitempty"` + Release *release `json:"release,omitempty"` + Repo *repo `json:"repository,omitempty"` +} + +func (r *releaseEvent) GetAction() string { + if r.Action != nil { + return *r.Action + } + return "" +} + +func (r *releaseEvent) GetRelease() *release { + return r.Release +} + +func (r *releaseEvent) GetRepo() *repo { + return r.Repo +} + +type release struct { + ID *int64 `json:"id,omitempty"` + TagName *string `json:"tag_name,omitempty"` + Target *string `json:"target_commitish,omitempty"` +} + +func (r *release) GetID() int64 { + if r.ID != nil { + return *r.ID + } + return 0 +} + +func (r *release) GetTagName() string { + if r.TagName != nil { + return *r.TagName + } + return "" +} + +func (r *release) GetTarget() string { + if r.Target != nil { + return *r.Target + } + return "" +} + +func processReleaseEvent( + ctx context.Context, + payload []byte, +) (*processingResult, error) { + var event *releaseEvent + if err := json.Unmarshal(payload, &event); err != nil { + return nil, fmt.Errorf("failed to unmarshal release event: %w", err) + } + + if event.GetAction() == "" { + return nil, errors.New("release event action not found") + } + + if event.GetRelease() == nil { + return nil, errors.New("release event release not found") + } + + if event.GetRepo() == nil { + return nil, errors.New("release event repository not found") + } + + if event.GetRelease().GetTagName() == "" { + return nil, errors.New("release event tag name not found") + } + + if event.GetRelease().GetTarget() == "" { + return nil, errors.New("release event target not found") + } + + return sendReleaseEvent(ctx, event) +} + +func sendReleaseEvent( + _ context.Context, + event *releaseEvent, +) (*processingResult, error) { + lookByProps, err := properties.NewProperties(map[string]any{ + properties.PropertyUpstreamID: properties.NumericalValueToUpstreamID(event.GetRelease().GetID()), + ghprop.ReleasePropertyOwner: event.GetRepo().GetOwner(), + ghprop.ReleasePropertyRepo: event.GetRepo().GetName(), + }) + if err != nil { + return nil, fmt.Errorf("error creating release properties: %w", err) + } + + originatorProps, err := properties.NewProperties(map[string]any{ + properties.PropertyUpstreamID: properties.NumericalValueToUpstreamID(event.GetRepo().GetID()), + }) + if err != nil { + return nil, fmt.Errorf("error creating repository properties for release origination: %w", err) + } + + switch event.GetAction() { + case "published": + return &processingResult{ + topic: constants.TopicQueueOriginatingEntityAdd, + wrapper: entityMessage.NewEntityRefreshAndDoMessage(). + WithEntity(pb.Entity_ENTITY_RELEASE, lookByProps). + WithProviderImplementsHint(string(db.ProviderTypeGithub)). + WithOriginator(pb.Entity_ENTITY_REPOSITORIES, originatorProps), + }, nil + case "unpublished", "deleted": + return &processingResult{ + topic: constants.TopicQueueOriginatingEntityDelete, + wrapper: entityMessage.NewEntityRefreshAndDoMessage(). + WithEntity(pb.Entity_ENTITY_RELEASE, lookByProps). + WithProviderImplementsHint(string(db.ProviderTypeGithub)). + WithOriginator(pb.Entity_ENTITY_REPOSITORIES, originatorProps), + }, nil + case "edited": + return &processingResult{ + topic: constants.TopicQueueRefreshEntityAndEvaluate, + wrapper: entityMessage.NewEntityRefreshAndDoMessage(). + WithEntity(pb.Entity_ENTITY_RELEASE, lookByProps). + WithProviderImplementsHint(string(db.ProviderTypeGithub)). + WithOriginator(pb.Entity_ENTITY_REPOSITORIES, originatorProps), + }, nil + } + return nil, nil +} diff --git a/internal/providers/github/webhook/hook.go b/internal/providers/github/webhook/hook.go index 14e3236ec2..a4d42d8f37 100644 --- a/internal/providers/github/webhook/hook.go +++ b/internal/providers/github/webhook/hook.go @@ -160,6 +160,9 @@ func HandleWebhookEvent( case "pull_request": wes.Accepted = true res, processingErr = processPullRequestEvent(ctx, rawWBPayload) + case "release": + wes.Accepted = true + res, processingErr = processReleaseEvent(ctx, rawWBPayload) case "ping": // For ping events, we do not set wes.Accepted // to true because they're not relevant