Skip to content
Open
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
7 changes: 7 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ proxy:
# repository: "ethereum/consensus-specs" # GitHub owner/repo (for forks)
# ref: "" # Branch, tag, or SHA (empty = latest release)

# Notification defaults shared by long-running commands (e.g. panda build).
# Persisted to config.user.yaml the first time `panda build --discord-dm <user>`
# is invoked, so subsequent builds DM you without re-passing the flag.
# notifications:
# discord:
# username: "barnabasbusa" # Discord username or user ID

# Observability configuration
observability:
metrics_enabled: true
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.41.0
golang.org/x/term v0.40.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
)
Expand Down Expand Up @@ -103,7 +104,6 @@ require (
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
Expand Down
90 changes: 90 additions & 0 deletions pkg/cli/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (

"github.com/spf13/cobra"

"github.com/ethpandaops/panda/pkg/config"
"github.com/ethpandaops/panda/pkg/configpath"
"github.com/ethpandaops/panda/pkg/serverapi"
)

Expand All @@ -21,6 +23,8 @@ var (
buildRepo string
buildDockerTag string
buildWait bool
buildDiscordDM string
buildNoDiscord bool
)

var buildCmd = &cobra.Command{
Expand Down Expand Up @@ -65,17 +69,24 @@ func init() {
buildCmd.Flags().StringVar(&buildRepo, "repo", "", "source repository override (e.g. user/go-ethereum)")
buildCmd.Flags().StringVar(&buildDockerTag, "tag", "", "override target docker tag")
buildCmd.Flags().BoolVar(&buildWait, "wait", false, "block until the build completes instead of returning immediately")
buildCmd.Flags().StringVar(&buildDiscordDM, "discord-dm", "",
"Discord username or user ID to DM on build completion (default: notifications.discord.username in config)")
buildCmd.Flags().BoolVar(&buildNoDiscord, "no-discord-dm", false,
"disable Discord notification for this build, even if a default username is configured")
}

func runBuild(_ *cobra.Command, args []string) error {
client := args[0]
ctx := context.Background()

discordUser := resolveDiscordTarget()

resp, err := triggerBuild(ctx, serverapi.BuildTriggerRequest{
Client: client,
Repository: buildRepo,
Ref: buildRef,
DockerTag: buildDockerTag,
Notify: buildNotifySpec(discordUser),
})
if err != nil {
return fmt.Errorf("triggering build: %w", err)
Expand Down Expand Up @@ -196,6 +207,85 @@ func printBuildTriggered(resp *serverapi.BuildTriggerResponse) error {
return nil
}

// resolveDiscordTarget picks the Discord recipient for this build invocation.
// Precedence: --no-discord-dm wins; otherwise --discord-dm; otherwise the
// configured default in notifications.discord.username. When --discord-dm is
// supplied but the configured default is empty, the value is persisted to
// config.user.yaml so subsequent builds notify without re-passing the flag.
func resolveDiscordTarget() string {
if buildNoDiscord {
return ""
}

configured := loadDiscordDefault()

if buildDiscordDM == "" {
return configured
}

if configured == "" {
if err := persistDiscordDefault(buildDiscordDM); err != nil {
fmt.Fprintf(os.Stderr, " (could not save Discord username to config: %v)\n", err)
} else {
fmt.Fprintf(os.Stderr, " Saved %q as default for future builds (override with --discord-dm or --no-discord-dm)\n", buildDiscordDM)
}
}

return buildDiscordDM
}

func buildNotifySpec(discordUser string) *serverapi.BuildNotifySpec {
if discordUser == "" {
return nil
}

return &serverapi.BuildNotifySpec{DiscordUser: discordUser}
}

func loadDiscordDefault() string {
resolved, err := configpath.ResolveAppConfigPath(cfgFile)
if err != nil {
return ""
}

cfg, err := config.LoadWithUserOverrides(resolved)
if err != nil {
return ""
}

return cfg.Notifications.Discord.Username
}

func persistDiscordDefault(username string) error {
resolved, err := configpath.ResolveAppConfigPath(cfgFile)
if err != nil {
return err
}

userPath := config.UserConfigPath(resolved)

overrides, err := config.LoadUserConfigMap(userPath)
if err != nil {
return err
}

notifications, _ := overrides["notifications"].(map[string]any)
if notifications == nil {
notifications = make(map[string]any, 1)
}

discord, _ := notifications["discord"].(map[string]any)
if discord == nil {
discord = make(map[string]any, 1)
}

discord["username"] = username
notifications["discord"] = discord
overrides["notifications"] = notifications

return config.SaveUserConfig(userPath, overrides)
}

func pollBuildStatus(ctx context.Context, runID int64) (*serverapi.BuildStatusResponse, error) {
ticker := time.NewTicker(buildPollInterval)
defer ticker.Stop()
Expand Down
112 changes: 104 additions & 8 deletions pkg/cli/init.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"bufio"
"context"
"encoding/json"
"fmt"
Expand All @@ -17,6 +18,7 @@ import (
dockerimage "github.com/docker/docker/api/types/image"
dockerclient "github.com/docker/docker/client"
"github.com/spf13/cobra"
"golang.org/x/term"

authclient "github.com/ethpandaops/panda/pkg/auth/client"
authstore "github.com/ethpandaops/panda/pkg/auth/store"
Expand All @@ -32,14 +34,16 @@ const (
)

var (
initDir = configpath.DefaultConfigDir()
initForce bool
initProxyURL string
initSandboxImage string
initServerImage string
initSkipDocker bool
initSkipAuth bool
initSkipStart bool
initDir = configpath.DefaultConfigDir()
initForce bool
initProxyURL string
initSandboxImage string
initServerImage string
initSkipDocker bool
initSkipAuth bool
initSkipStart bool
initDiscordUsername string
initSkipDiscord bool
)

var initCmd = &cobra.Command{
Expand Down Expand Up @@ -73,6 +77,10 @@ func init() {
initCmd.Flags().BoolVar(&initSkipStart, "skip-start", false, "skip starting the server")
initCmd.Flags().BoolVar(&noBrowser, "no-browser", false,
"manual auth flow for SSH/headless environments (auto-detected over SSH)")
initCmd.Flags().StringVar(&initDiscordUsername, "discord-username", "",
"Discord username or user ID to DM on build completion (skips interactive prompt)")
initCmd.Flags().BoolVar(&initSkipDiscord, "skip-discord", false,
"skip the Discord username prompt entirely")
}

func runInit(_ *cobra.Command, _ []string) error {
Expand Down Expand Up @@ -162,6 +170,11 @@ func runInit(_ *cobra.Command, _ []string) error {
fmt.Println("\nSkipping authentication (--skip-auth)")
}

// 4b. Discord username (optional, persisted to config.user.yaml).
if err := initEnsureDiscordUsername(configPath); err != nil {
fmt.Fprintf(os.Stderr, " (could not save Discord username: %v)\n", err)
}

// 5. Start the server.
switch {
case initSkipStart:
Expand Down Expand Up @@ -495,3 +508,86 @@ func initEnsureAuth() (bool, error) {

return false, runAuthLogin(nil, nil)
}

// initEnsureDiscordUsername gathers an optional Discord username and persists
// it under notifications.discord.username so future `panda build` invocations
// notify the user without needing the --discord-dm flag. The prompt is
// suppressed under --skip-discord, when stdin is not a TTY, or when a value is
// already configured. A value supplied via --discord-username always wins.
func initEnsureDiscordUsername(configPath string) error {
if initSkipDiscord {
return nil
}

username := strings.TrimSpace(initDiscordUsername)

if username == "" {
existing, _ := loadConfiguredDiscordUsername(configPath)
if existing != "" {
fmt.Printf("Discord notifications: already configured for %q\n", existing)
return nil
}

if !term.IsTerminal(int(os.Stdin.Fd())) {
return nil
}

fmt.Println()
fmt.Println("Optional: Discord username for build-completion DMs.")
fmt.Println(" Leave blank to skip; you can set it later with --discord-dm on `panda build`.")
fmt.Print("Discord username: ")

scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return nil
}

username = strings.TrimSpace(scanner.Text())
}

if username == "" {
return nil
}

if err := saveDiscordUsername(configPath, username); err != nil {
return err
}

fmt.Printf("Saved Discord username %q to config.user.yaml\n", username)

return nil
}

func loadConfiguredDiscordUsername(configPath string) (string, error) {
cfg, err := config.LoadWithUserOverrides(configPath)
if err != nil {
return "", err
}

return cfg.Notifications.Discord.Username, nil
}

func saveDiscordUsername(configPath, username string) error {
userPath := config.UserConfigPath(configPath)

overrides, err := config.LoadUserConfigMap(userPath)
if err != nil {
return err
}

notifications, _ := overrides["notifications"].(map[string]any)
if notifications == nil {
notifications = make(map[string]any, 1)
}

discord, _ := notifications["discord"].(map[string]any)
if discord == nil {
discord = make(map[string]any, 1)
}

discord["username"] = username
notifications["discord"] = discord
overrides["notifications"] = notifications

return config.SaveUserConfig(userPath, overrides)
}
16 changes: 16 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,26 @@ type Config struct {
Storage StorageConfig `yaml:"storage"`
Observability ObservabilityConfig `yaml:"observability"`
ConsensusSpecs ConsensusSpecsConfig `yaml:"consensus_specs,omitempty"`
Notifications NotificationsConfig `yaml:"notifications,omitempty"`

path string `yaml:"-"`
}

// NotificationsConfig holds per-user notification preferences shared by panda
// CLI commands that produce long-running results (e.g. panda build).
type NotificationsConfig struct {
// Discord configures Discord notifications.
Discord DiscordNotificationConfig `yaml:"discord,omitempty"`
}

// DiscordNotificationConfig holds Discord notification preferences.
type DiscordNotificationConfig struct {
// Username is the Discord username or user ID to DM on build completion.
// When set, panda build will auto-include it in trigger requests.
// Override with --discord-dm <user> or disable with --no-discord-dm.
Username string `yaml:"username,omitempty"`
}

// ConsensusSpecsConfig configures how consensus-specs are fetched from GitHub.
type ConsensusSpecsConfig struct {
// Repository is the GitHub owner/repo (e.g. "ethereum/consensus-specs").
Expand Down
Loading
Loading