Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ These flags are available on all commands:
- **`--dependabot-security-updates-available string`** (`-s`) - Whether Dependabot Security Updates are available in your GHES instance (true/false)
- **`--config-name string`** (`-n`) - Name of the security configuration to operate on. Replaces the interactive configuration-name prompt for each command (the meaning is command-specific: the name to create in `generate`, the name to select in `apply`/`delete`/`modify`, or the name of the source config in `generate --copy-from-org`).
- **`--skip-confirmation-message string`** - Automatically approve the final confirmation prompt for any command (`true`/`false`).
- **`--log-level string`** - Minimum log level for output (`info`, `warning`, `error`; default: `warning`). When set to `info`, a success message is printed for each organization that is processed successfully.

#### `generate` Command Flags

Expand Down
19 changes: 13 additions & 6 deletions cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func runApply(cmd *cobra.Command, args []string) error {
pterm.Info.Println("Detecting GitHub Enterprise Server version...")
ghesVersion, err := api.GetGHESVersion()
if err != nil {
pterm.Warning.Printf("Could not detect GHES version: %v\n", err)
ui.LogWarningf("Could not detect GHES version: %v", err)
pterm.Info.Println("Assuming enterprise configurations are not available")
ghesVersion = ""
} else if ghesVersion != "" {
Expand All @@ -145,7 +145,7 @@ func runApply(cmd *cobra.Command, args []string) error {
pterm.Info.Println("Fetching enterprise security configurations...")
enterpriseConfigs, err := api.FetchEnterpriseSecurityConfigurations(enterprise)
if err != nil {
pterm.Warning.Printf("Could not fetch enterprise configurations: %v\n", err)
ui.LogWarningf("Could not fetch enterprise configurations: %v", err)
} else {
for _, config := range enterpriseConfigs {
enterpriseConfigNames = append(enterpriseConfigNames, config.Name)
Expand Down Expand Up @@ -203,14 +203,14 @@ func runApply(cmd *cobra.Command, args []string) error {
status, err := api.CheckSingleOrganizationMembership(templateOrg)
if err != nil || !status.IsMember || !status.IsOwner {
if err != nil {
pterm.Warning.Printf("Could not access template organization '%s': %v\n", templateOrg, err)
ui.LogWarningf("Could not access template organization '%s': %v", templateOrg, err)
} else {
pterm.Warning.Printf("You must be an owner of template organization '%s' to fetch configurations\n", templateOrg)
ui.LogWarningf("You must be an owner of template organization '%s' to fetch configurations", templateOrg)
}
} else {
configs, err := api.FetchSecurityConfigurations(templateOrg)
if err != nil {
pterm.Warning.Printf("Could not fetch configurations from template organization '%s': %v\n", templateOrg, err)
ui.LogWarningf("Could not fetch configurations from template organization '%s': %v", templateOrg, err)
} else {
for _, config := range configs {
// Only add organization-level configs (not enterprise configs shown at org level)
Expand Down Expand Up @@ -334,18 +334,25 @@ func runApply(cmd *cobra.Command, args []string) error {

utils.PrintCompletionHeader("Security Configuration Application", successCount, skippedCount, errorCount)

// Extract log level flag
logLevel, err := cmd.Flags().GetString("log-level")
if err != nil {
return err
}

// Build and display replication command
replicationFlags := map[string]interface{}{
"enterprise-slug": enterprise,
"github-enterprise-server-url": serverURL,
"template-org": templateOrg,
"concurrency": commonFlags.Concurrency,
"delay": commonFlags.Delay,
"log-level": logLevel,
"config-name": configName,
"config-source": targetType,
"scope": scope,
"set-as-default": fmt.Sprintf("%t", setAsDefault),
"skip-confirmation-message": fmt.Sprintf("%t", force),
"skip-confirmation-message": fmt.Sprintf("%t", force),
}

// Add org targeting flags
Expand Down
17 changes: 12 additions & 5 deletions cmd/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,15 @@ func runDelete(cmd *cobra.Command, args []string) error {
pterm.Info.Printf("Fetching security configurations from template organization '%s'...\n", templateOrg)
status, err := api.CheckSingleOrganizationMembership(templateOrg)
if err != nil {
pterm.Warning.Printf("Could not access template organization '%s': %v\n", templateOrg, err)
ui.LogWarningf("Could not access template organization '%s': %v", templateOrg, err)
} else if !status.IsMember {
pterm.Warning.Printf("You must be a member of template organization '%s' to fetch configurations\n", templateOrg)
ui.LogWarningf("You must be a member of template organization '%s' to fetch configurations", templateOrg)
} else if !status.IsOwner {
pterm.Warning.Printf("You must be an owner of template organization '%s' to fetch configurations\n", templateOrg)
ui.LogWarningf("You must be an owner of template organization '%s' to fetch configurations", templateOrg)
} else {
configs, err := api.FetchSecurityConfigurations(templateOrg)
if err != nil {
pterm.Warning.Printf("Could not fetch configurations from template organization '%s': %v\n", templateOrg, err)
ui.LogWarningf("Could not fetch configurations from template organization '%s': %v", templateOrg, err)
} else {
for _, config := range configs {
// Only add organization-level configs (not enterprise configs shown at org level)
Expand Down Expand Up @@ -209,15 +209,22 @@ func runDelete(cmd *cobra.Command, args []string) error {

utils.PrintCompletionHeader("Security Configuration Deletion", successCount, skippedCount, errorCount)

// Extract log level flag
logLevel, err := cmd.Flags().GetString("log-level")
if err != nil {
return err
}

// Build and display replication command
replicationFlags := map[string]interface{}{
"enterprise-slug": enterprise,
"github-enterprise-server-url": serverURL,
"template-org": templateOrg,
"concurrency": commonFlags.Concurrency,
"delay": commonFlags.Delay,
"log-level": logLevel,
"config-name": configName,
"skip-confirmation-message": fmt.Sprintf("%t", force),
"skip-confirmation-message": fmt.Sprintf("%t", force),
}

// Add org targeting flags
Expand Down
7 changes: 7 additions & 0 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,12 @@ func runGenerate(cmd *cobra.Command, args []string) error {

utils.PrintCompletionHeader("Security Configuration Generation", successCount, skippedCount, errorCount)

// Extract log level flag
logLevel, err := cmd.Flags().GetString("log-level")
if err != nil {
return err
}

// Build and display replication command
replicationFlags := map[string]interface{}{
"enterprise-slug": enterprise,
Expand All @@ -285,6 +291,7 @@ func runGenerate(cmd *cobra.Command, args []string) error {
"dependabot-security-updates-available": fmt.Sprintf("%t", dependabotSecurityUpdatesAvailable),
"concurrency": commonFlags.Concurrency,
"delay": commonFlags.Delay,
"log-level": logLevel,
"config-name": configName,
"scope": scope,
"set-as-default": fmt.Sprintf("%t", setAsDefault),
Expand Down
23 changes: 15 additions & 8 deletions cmd/modify.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func runModify(cmd *cobra.Command, args []string) error {
ghesVersion, err := api.GetGHESVersion()
var enterpriseConfigCount int
if err != nil {
pterm.Warning.Printf("Could not detect GHES version: %v\n", err)
ui.LogWarningf("Could not detect GHES version: %v", err)
pterm.Info.Println("Assuming enterprise configurations are not available")
ghesVersion = ""
} else if ghesVersion != "" {
Expand All @@ -129,7 +129,7 @@ func runModify(cmd *cobra.Command, args []string) error {
pterm.Info.Println("Fetching enterprise security configurations...")
enterpriseConfigs, err := api.FetchEnterpriseSecurityConfigurations(enterprise)
if err != nil {
pterm.Warning.Printf("Could not fetch enterprise configurations: %v\n", err)
ui.LogWarningf("Could not fetch enterprise configurations: %v", err)
} else {
enterpriseConfigCount = len(enterpriseConfigs)
if enterpriseConfigCount > 0 {
Expand Down Expand Up @@ -184,15 +184,15 @@ func runModify(cmd *cobra.Command, args []string) error {
var orgConfigNames []string
status, err := api.CheckSingleOrganizationMembership(templateOrg)
if err != nil {
pterm.Warning.Printf("Could not access template organization '%s': %v\n", templateOrg, err)
ui.LogWarningf("Could not access template organization '%s': %v", templateOrg, err)
} else if !status.IsMember {
pterm.Warning.Printf("You must be a member of template organization '%s' to fetch configurations\n", templateOrg)
ui.LogWarningf("You must be a member of template organization '%s' to fetch configurations", templateOrg)
} else if !status.IsOwner {
pterm.Warning.Printf("You must be an owner of template organization '%s' to fetch configurations\n", templateOrg)
ui.LogWarningf("You must be an owner of template organization '%s' to fetch configurations", templateOrg)
} else {
configs, err := api.FetchSecurityConfigurations(templateOrg)
if err != nil {
pterm.Warning.Printf("Could not fetch configurations from template organization '%s': %v\n", templateOrg, err)
ui.LogWarningf("Could not fetch configurations from template organization '%s': %v", templateOrg, err)
} else {
for _, config := range configs {
// Only add organization-level configs (not enterprise configs shown at org level)
Expand Down Expand Up @@ -261,7 +261,7 @@ func runModify(cmd *cobra.Command, args []string) error {
}

if currentSettings == nil {
pterm.Warning.Printf("Configuration '%s' not found in template organization '%s'.\n", configName, templateOrg)
ui.LogWarningf("Configuration '%s' not found in template organization '%s'.", configName, templateOrg)
return fmt.Errorf("configuration '%s' not found in template org", configName)
}

Expand Down Expand Up @@ -321,6 +321,12 @@ func runModify(cmd *cobra.Command, args []string) error {

utils.PrintCompletionHeader("Security Configuration Modification", successCount, skippedCount, errorCount)

// Extract log level flag
logLevel, err := cmd.Flags().GetString("log-level")
if err != nil {
return err
}

// Build and display replication command
replicationFlags := map[string]interface{}{
"enterprise-slug": enterprise,
Expand All @@ -330,6 +336,7 @@ func runModify(cmd *cobra.Command, args []string) error {
"dependabot-security-updates-available": fmt.Sprintf("%t", dependabotSecurityUpdatesAvailable),
"concurrency": commonFlags.Concurrency,
"delay": commonFlags.Delay,
"log-level": logLevel,
"config-name": configName,
"new-name": newName,
"new-description": newDescription,
Expand All @@ -338,7 +345,7 @@ func runModify(cmd *cobra.Command, args []string) error {
"secret-scanning-push-protection": fmt.Sprintf("%v", newSettings["secret_scanning_push_protection"]),
"secret-scanning-non-provider-patterns": fmt.Sprintf("%v", newSettings["secret_scanning_non_provider_patterns"]),
"enforcement": fmt.Sprintf("%v", newSettings["enforcement"]),
"skip-confirmation-message": fmt.Sprintf("%t", force),
"skip-confirmation-message": fmt.Sprintf("%t", force),
}
if v, ok := newSettings["dependabot_alerts"]; ok {
replicationFlags["dependabot-alerts"] = fmt.Sprintf("%v", v)
Expand Down
17 changes: 17 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package cmd

import (
"fmt"
"os"
"strings"

"github.com/pterm/pterm"
"github.com/spf13/cobra"

"github.com/callmegreg/gh-security-config/internal/ui"
)

var rootCmd = &cobra.Command{
Expand All @@ -14,6 +18,18 @@ var rootCmd = &cobra.Command{
CompletionOptions: cobra.CompletionOptions{
HiddenDefaultCmd: true,
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
levelStr, err := cmd.Flags().GetString("log-level")
if err != nil {
return err
}
level, err := ui.ParseLogLevel(levelStr)
if err != nil {
return err
}
ui.SetLogLevel(level)
return nil
},
}

func init() {
Expand All @@ -33,6 +49,7 @@ func init() {
// Flags shared by all subcommands
rootCmd.PersistentFlags().StringP("config-name", "n", "", "Name of the security configuration to operate on (replaces the interactive configuration-name prompt for each command)")
rootCmd.PersistentFlags().String("skip-confirmation-message", "", "Automatically approve the final confirmation prompt for any command (true/false)")
rootCmd.PersistentFlags().String("log-level", ui.LogLevelDefault, fmt.Sprintf("Minimum log level for output (%s)", strings.Join(ui.LogLevelValues, ", ")))

// Mark org targeting flags as mutually exclusive
rootCmd.MarkFlagsMutuallyExclusive("org", "org-list", "all-orgs")
Expand Down
14 changes: 7 additions & 7 deletions internal/api/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/cli/go-gh/v2"
"github.com/pterm/pterm"

"github.com/callmegreg/gh-security-config/internal/loglevel"
"github.com/callmegreg/gh-security-config/internal/types"
)

Expand Down Expand Up @@ -44,7 +45,9 @@ func CheckSingleOrganizationMembership(org string) (types.MembershipStatus, erro
}

if err := json.Unmarshal(userResponse.Bytes(), &membership); err != nil {
pterm.Warning.Printf("Failed to parse membership data for organization '%s': %v\n", org, err)
if loglevel.WarningEnabled() {
pterm.Warning.Printf("Failed to parse membership data for organization '%s': %v\n", org, err)
}
return types.MembershipStatus{IsMember: false, IsOwner: false, Role: "none"}, nil
}

Expand All @@ -66,16 +69,13 @@ func CheckSingleOrganizationMembership(org string) (types.MembershipStatus, erro
func ValidateMembershipAndSkip(org string) *types.ProcessingResult {
status, err := CheckSingleOrganizationMembership(org)
if err != nil {
pterm.Warning.Printf("Failed to check membership for organization '%s': %v, skipping\n", org, err)
return &types.ProcessingResult{Organization: org, Skipped: true}
return &types.ProcessingResult{Organization: org, Skipped: true, SkipReason: fmt.Sprintf("Failed to check membership for organization '%s': %v, skipping", org, err)}
}
if !status.IsMember {
pterm.Warning.Printf("Skipping organization '%s': You are not a member\n", org)
return &types.ProcessingResult{Organization: org, Skipped: true}
return &types.ProcessingResult{Organization: org, Skipped: true, SkipReason: fmt.Sprintf("Skipping organization '%s': You are not a member", org)}
}
if !status.IsOwner {
pterm.Warning.Printf("Skipping organization '%s': You are a member but not an owner\n", org)
return &types.ProcessingResult{Organization: org, Skipped: true}
return &types.ProcessingResult{Organization: org, Skipped: true, SkipReason: fmt.Sprintf("Skipping organization '%s': You are a member but not an owner", org)}
}
return nil // No skip needed
}
77 changes: 77 additions & 0 deletions internal/loglevel/loglevel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Package loglevel manages the global log verbosity for the extension.
// It is intentionally free of internal dependencies so that any package
// (including api and utils) can check the current level without import cycles.
package loglevel

import (
"fmt"
"strings"
"sync"
)

// LogLevel represents the verbosity of output emitted by the extension.
type LogLevel int

const (
// LogLevelInfo emits informational messages in addition to warnings and errors.
LogLevelInfo LogLevel = iota
// LogLevelWarning (the default) emits warnings and errors but suppresses info messages.
LogLevelWarning
// LogLevelError emits only errors.
LogLevelError
)

// LogLevelDefault is the default log level used when the user does not set one.
const LogLevelDefault = "warning"

// LogLevelValues lists the accepted values for the --log-level flag.
var LogLevelValues = []string{"info", "warning", "error"}

var (
logLevelMu sync.RWMutex
logLevel = LogLevelWarning
)

// ParseLogLevel converts a user-supplied string to a LogLevel. The comparison is
// case-insensitive and whitespace is trimmed. An empty string resolves to the
// default level.
func ParseLogLevel(value string) (LogLevel, error) {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
normalized = LogLevelDefault
}
switch normalized {
case "info":
return LogLevelInfo, nil
case "warning":
return LogLevelWarning, nil
case "error":
return LogLevelError, nil
default:
return LogLevelWarning, fmt.Errorf("invalid value for log-level flag: %q (must be one of: %s)", value, strings.Join(LogLevelValues, ", "))
}
}

// SetLogLevel updates the package-level log level. Safe for concurrent use.
func SetLogLevel(level LogLevel) {
logLevelMu.Lock()
defer logLevelMu.Unlock()
logLevel = level
}

// GetLogLevel returns the current log level. Safe for concurrent use.
func GetLogLevel() LogLevel {
logLevelMu.RLock()
defer logLevelMu.RUnlock()
return logLevel
}

// WarningEnabled reports whether warning messages should be emitted.
func WarningEnabled() bool {
return GetLogLevel() <= LogLevelWarning
}

// InfoEnabled reports whether informational messages should be emitted.
func InfoEnabled() bool {
return GetLogLevel() <= LogLevelInfo
}
Loading
Loading