From 94abda47bc509b5857aee993f8bca7ca516dc8c9 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 12 Jan 2026 09:53:34 -0300 Subject: [PATCH 1/2] feat: monitor deployments Signed-off-by: Gustavo Carvalho --- main.go | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 28ca17b..969a420 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" "strings" + "time" policyManager "github.com/compliance-framework/agent/policy-manager" "github.com/compliance-framework/agent/runner" @@ -23,10 +24,13 @@ type Validator interface { } type PluginConfig struct { - Token string `mapstructure:"token"` - Organization string `mapstructure:"organization"` - IncludedRepositories string `mapstructure:"included_repositories"` - ExcludedRepositories string `mapstructure:"excluded_repositories"` + Token string `mapstructure:"token"` + Organization string `mapstructure:"organization"` + IncludedRepositories string `mapstructure:"included_repositories"` + ExcludedRepositories string `mapstructure:"excluded_repositories"` + DeploymentLookbackDays int `mapstructure:"deployment_lookback_days"` // Number of days to look back for deployments (default: 90) + OnlyActiveDeployments bool `mapstructure:"only_active_deployments"` // Only fetch deployments that are still active (not superseded) (default: true) + IncludeFailedDeployments bool `mapstructure:"include_failed_deployments"` // Include deployments with failure/error states (default: false) } func (c *PluginConfig) Validate() error { @@ -45,6 +49,11 @@ func (c *PluginConfig) Validate() error { return nil } +type DeploymentWithStatuses struct { + Deployment *github.Deployment `json:"deployment"` + Statuses []*github.DeploymentStatus `json:"statuses"` +} + type SaturatedRepository struct { Settings *github.Repository `json:"settings"` Workflows []*github.Workflow `json:"workflows"` @@ -60,6 +69,7 @@ type SaturatedRepository struct { OpenPullRequests []*OpenPullRequest `json:"pull_requests"` CodeOwners *github.RepositoryContent `json:"code_owners"` OrgTeams []*OrgTeam `json:"org_teams"` + Deployments []*DeploymentWithStatuses `json:"deployments"` } type GithubReposPlugin struct { @@ -84,6 +94,16 @@ func (l *GithubReposPlugin) Configure(req *proto.ConfigureRequest) (*proto.Confi return nil, err } + // Set default deployment lookback period if not specified + if config.DeploymentLookbackDays == 0 { + config.DeploymentLookbackDays = 90 + } + + // Default to only active deployments (not superseded) + // Note: OnlyActiveDeployments defaults to false (zero value), so we need to check if it was explicitly set + // For now, we'll treat false as "fetch all" and true as "only active" + // IncludeFailedDeployments defaults to false, which means we skip failed deployments by default + l.config = config httpClient := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{ AccessToken: config.Token, @@ -220,6 +240,13 @@ func (l *GithubReposPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHel Status: proto.ExecutionStatus_FAILURE, }, err } + deployments, err := l.FetchDeploymentsWithStatuses(ctx, repo) + if err != nil { + l.Logger.Error("error gathering deployments", "error", err) + return &proto.EvalResponse{ + Status: proto.ExecutionStatus_FAILURE, + }, err + } data := &SaturatedRepository{ Settings: repo, Workflows: workflows, @@ -232,6 +259,7 @@ func (l *GithubReposPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHel OpenPullRequests: openPullRequests, CodeOwners: codeOwners, OrgTeams: orgTeams, + Deployments: deployments, } // Uncomment to check the data that is being passed through from // the client, as data formats are often slightly different than @@ -359,6 +387,91 @@ func (l *GithubReposPlugin) GatherWorkflowRuns(ctx context.Context, repo *github return workflowRuns.WorkflowRuns, nil } +func (l *GithubReposPlugin) FetchDeploymentsWithStatuses(ctx context.Context, repo *github.Repository) ([]*DeploymentWithStatuses, error) { + owner := repo.GetOwner().GetLogin() + name := repo.GetName() + + // Calculate cutoff time based on configured lookback period + cutoffTime := time.Now().AddDate(0, 0, -l.config.DeploymentLookbackDays) + + opts := &github.DeploymentsListOptions{ + ListOptions: github.ListOptions{PerPage: 100, Page: 1}, + } + + var deploymentsWithStatuses []*DeploymentWithStatuses + + for { + deployments, resp, err := l.githubClient.Repositories.ListDeployments(ctx, owner, name, opts) + if err != nil { + if isPermissionError(err) { + l.Logger.Trace("No permission to fetch deployments", "repo", repo.GetFullName()) + return nil, nil + } + return nil, err + } + + for _, deployment := range deployments { + // Skip deployments older than the lookback period + if deployment.CreatedAt != nil && deployment.CreatedAt.Before(cutoffTime) { + l.Logger.Trace("Skipping old deployment", "deployment_id", deployment.GetID(), "created_at", deployment.CreatedAt.Time, "cutoff", cutoffTime) + continue + } + + statuses, _, err := l.githubClient.Repositories.ListDeploymentStatuses(ctx, owner, name, deployment.GetID(), &github.ListOptions{PerPage: 100}) + if err != nil { + l.Logger.Warn("Error fetching deployment statuses", "deployment_id", deployment.GetID(), "error", err) + continue + } + + // Check if deployment should be filtered based on status + if l.shouldSkipDeployment(deployment, statuses) { + continue + } + + deploymentsWithStatuses = append(deploymentsWithStatuses, &DeploymentWithStatuses{ + Deployment: deployment, + Statuses: statuses, + }) + } + + if resp == nil || resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + l.Logger.Debug("Fetched deployments", "repo", repo.GetFullName(), "count", len(deploymentsWithStatuses), "lookback_days", l.config.DeploymentLookbackDays) + return deploymentsWithStatuses, nil +} + +// shouldSkipDeployment determines if a deployment should be filtered out based on configuration +func (l *GithubReposPlugin) shouldSkipDeployment(deployment *github.Deployment, statuses []*github.DeploymentStatus) bool { + if len(statuses) == 0 { + // No statuses yet - include it (deployment is pending) + return false + } + + // Get the latest status (statuses are returned in reverse chronological order) + latestStatus := statuses[0] + latestState := latestStatus.GetState() + + // Filter inactive deployments if OnlyActiveDeployments is enabled + if l.config.OnlyActiveDeployments && latestState == "inactive" { + l.Logger.Trace("Skipping inactive deployment", "deployment_id", deployment.GetID(), "state", latestState) + return true + } + + // Filter failed/error deployments if IncludeFailedDeployments is false (default) + if !l.config.IncludeFailedDeployments { + if latestState == "failure" || latestState == "error" { + l.Logger.Trace("Skipping failed deployment", "deployment_id", deployment.GetID(), "state", latestState) + return true + } + } + + return false +} + func (l *GithubReposPlugin) ListProtectedBranches(ctx context.Context, repo *github.Repository) ([]*github.Branch, error) { owner := repo.GetOwner().GetLogin() name := repo.GetName() From bd7d39faa5e6bd9cfa17e50947edb173aa6412a7 Mon Sep 17 00:00:00 2001 From: Gustavo Fernandes de Carvalho <17139678+gusfcarvalho@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:03:56 -0300 Subject: [PATCH 2/2] Update main.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 969a420..4030bc8 100644 --- a/main.go +++ b/main.go @@ -29,7 +29,7 @@ type PluginConfig struct { IncludedRepositories string `mapstructure:"included_repositories"` ExcludedRepositories string `mapstructure:"excluded_repositories"` DeploymentLookbackDays int `mapstructure:"deployment_lookback_days"` // Number of days to look back for deployments (default: 90) - OnlyActiveDeployments bool `mapstructure:"only_active_deployments"` // Only fetch deployments that are still active (not superseded) (default: true) + OnlyActiveDeployments bool `mapstructure:"only_active_deployments"` // Only fetch deployments that are still active (not superseded) (default: false) IncludeFailedDeployments bool `mapstructure:"include_failed_deployments"` // Include deployments with failure/error states (default: false) }