Add branch lifecycle commands (branch, merge, archive) to the SDLC CLI. Introduce orchestrator handler and service for multi-step SDLC workflows. Expand skeleton template with 15 Claude commands covering the full feature lifecycle. Extend classifier rules, error types, and executor port for branch operations. Split rules.go and classifier_test.go to stay within 500-line limit. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
210 lines
8.0 KiB
Go
210 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/envutil"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/service"
|
|
"github.com/orchard9/rdev/internal/worker"
|
|
)
|
|
|
|
// Config holds application configuration.
|
|
type Config struct {
|
|
Port int
|
|
DBHost string
|
|
DBPort int
|
|
DBUser string
|
|
DBPassword string
|
|
DBName string
|
|
DBSSLMode string
|
|
AdminKey string
|
|
|
|
// Credential store encryption key (required for storing secrets in DB)
|
|
CredentialEncryptionKey string
|
|
|
|
// OpenCode configuration (optional - enables OpenCode as alternative code agent)
|
|
OpenCodeURL string // e.g., "http://opencode:4096"
|
|
OpenCodeUsername string // Basic auth username (default: "opencode")
|
|
OpenCodePassword string // Basic auth password
|
|
|
|
// Infrastructure adapters (threesix.ai) - fallback values if not in credential store
|
|
GiteaURL string
|
|
GiteaToken string
|
|
GiteaDefaultOrg string
|
|
CloudflareToken string
|
|
CloudflareZoneID string
|
|
DefaultDomain string
|
|
DeployNamespace string
|
|
DeployTLSIssuer string
|
|
ClusterIP string
|
|
RegistryURL string
|
|
WoodpeckerURL string
|
|
WoodpeckerAPIToken string
|
|
WoodpeckerWebhookSecret string
|
|
}
|
|
|
|
// InfraConfig holds infrastructure adapter configuration.
|
|
// Loaded from credential store with env var fallback.
|
|
type InfraConfig struct {
|
|
GiteaURL string
|
|
GiteaToken string
|
|
GiteaDefaultOrg string
|
|
CloudflareToken string
|
|
CloudflareZoneID string
|
|
DefaultDomain string
|
|
DeployNamespace string
|
|
DeployTLSIssuer string
|
|
ClusterIP string
|
|
RegistryURL string
|
|
WoodpeckerURL string
|
|
WoodpeckerAPIToken string
|
|
WoodpeckerWebhookSecret string
|
|
|
|
// CockroachDB provisioner (for project databases)
|
|
CRDBHost string // e.g., "cockroachdb-public.databases.svc.cluster.local"
|
|
CRDBPort int // e.g., 26257
|
|
CRDBUser string // e.g., "root" (insecure mode)
|
|
CRDBSSLMode string // e.g., "disable" (insecure) or "verify-full" (production)
|
|
|
|
// Redis provisioner (for project cache)
|
|
RedisHost string // e.g., "redis.databases.svc.cluster.local"
|
|
RedisPort int // e.g., 6379
|
|
RedisPassword string // admin password for ACL management
|
|
}
|
|
|
|
func loadConfig() Config {
|
|
return Config{
|
|
Port: envutil.GetEnvInt("PORT", 8080),
|
|
DBHost: envutil.GetEnv("DB_HOST", "postgres.databases.svc.cluster.local"),
|
|
DBPort: envutil.GetEnvInt("DB_PORT", 5432),
|
|
DBUser: envutil.GetEnv("DB_USER", "appuser"),
|
|
DBPassword: os.Getenv("DB_PASSWORD"),
|
|
DBName: envutil.GetEnv("DB_NAME", "rdev"),
|
|
DBSSLMode: envutil.GetEnv("DB_SSL_MODE", "disable"),
|
|
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
|
|
|
|
// Encryption key for credential store (generate with: openssl rand -base64 32)
|
|
// REQUIRED in production - no default to prevent insecure deployments
|
|
CredentialEncryptionKey: os.Getenv("CREDENTIAL_ENCRYPTION_KEY"),
|
|
|
|
// OpenCode (optional alternative code agent)
|
|
OpenCodeURL: os.Getenv("OPENCODE_URL"), // e.g., "http://opencode:4096"
|
|
OpenCodeUsername: envutil.GetEnv("OPENCODE_USERNAME", "opencode"),
|
|
OpenCodePassword: os.Getenv("OPENCODE_PASSWORD"),
|
|
|
|
// Infrastructure adapters (fallback if not in credential store)
|
|
GiteaURL: envutil.GetEnv("GITEA_URL", "https://git.threesix.ai"),
|
|
GiteaToken: os.Getenv("GITEA_TOKEN"),
|
|
GiteaDefaultOrg: envutil.GetEnv("GITEA_DEFAULT_ORG", "jordan"),
|
|
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
|
|
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
|
|
DefaultDomain: envutil.GetEnv("DEFAULT_DOMAIN", "threesix.ai"),
|
|
DeployNamespace: envutil.GetEnv("DEPLOY_NAMESPACE", "projects"),
|
|
DeployTLSIssuer: envutil.GetEnv("DEPLOY_TLS_ISSUER", "letsencrypt-prod"),
|
|
ClusterIP: envutil.GetEnv("CLUSTER_IP", "208.122.204.172"),
|
|
RegistryURL: envutil.GetEnv("REGISTRY_URL", "registry.threesix.ai"),
|
|
WoodpeckerURL: envutil.GetEnv("WOODPECKER_URL", "https://ci.threesix.ai"),
|
|
WoodpeckerAPIToken: os.Getenv("WOODPECKER_API_TOKEN"),
|
|
WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"),
|
|
}
|
|
}
|
|
|
|
// loadInfraConfig loads infrastructure configuration from credential store,
|
|
// falling back to environment variables if not found in the store.
|
|
func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config, logger *slog.Logger) InfraConfig {
|
|
// Try to load from credential store
|
|
creds, err := store.GetMultiple(ctx, []string{
|
|
domain.CredKeyGiteaToken,
|
|
domain.CredKeyGiteaURL,
|
|
domain.CredKeyCloudflareAPIToken,
|
|
domain.CredKeyCloudflareZoneID,
|
|
domain.CredKeyWoodpeckerURL,
|
|
domain.CredKeyWoodpeckerAPIToken,
|
|
domain.CredKeyWoodpeckerWebhookSecret,
|
|
domain.CredKeyRegistryURL,
|
|
})
|
|
if err != nil {
|
|
logger.Warn("failed to load credentials from store, using env vars", "error", err)
|
|
creds = make(map[string]string)
|
|
}
|
|
|
|
// Helper to get from store or fall back to env var
|
|
getOrFallback := func(key, envFallback string) string {
|
|
if v, ok := creds[key]; ok && v != "" {
|
|
return v
|
|
}
|
|
return envFallback
|
|
}
|
|
|
|
infraCfg := InfraConfig{
|
|
GiteaURL: getOrFallback(domain.CredKeyGiteaURL, cfg.GiteaURL),
|
|
GiteaToken: getOrFallback(domain.CredKeyGiteaToken, cfg.GiteaToken),
|
|
GiteaDefaultOrg: cfg.GiteaDefaultOrg, // Not a secret, use env
|
|
CloudflareToken: getOrFallback(domain.CredKeyCloudflareAPIToken, cfg.CloudflareToken),
|
|
CloudflareZoneID: getOrFallback(domain.CredKeyCloudflareZoneID, cfg.CloudflareZoneID),
|
|
DefaultDomain: cfg.DefaultDomain, // Not a secret, use env
|
|
DeployNamespace: cfg.DeployNamespace, // Not a secret, use env
|
|
DeployTLSIssuer: cfg.DeployTLSIssuer, // Not a secret, use env
|
|
ClusterIP: cfg.ClusterIP, // Not a secret, use env
|
|
RegistryURL: getOrFallback(domain.CredKeyRegistryURL, cfg.RegistryURL),
|
|
WoodpeckerURL: getOrFallback(domain.CredKeyWoodpeckerURL, cfg.WoodpeckerURL),
|
|
WoodpeckerAPIToken: getOrFallback(domain.CredKeyWoodpeckerAPIToken, cfg.WoodpeckerAPIToken),
|
|
WoodpeckerWebhookSecret: getOrFallback(domain.CredKeyWoodpeckerWebhookSecret, cfg.WoodpeckerWebhookSecret),
|
|
|
|
// CockroachDB and Redis provisioners (env-only for now)
|
|
CRDBHost: os.Getenv("CRDB_HOST"), // e.g., "cockroachdb-public.databases.svc.cluster.local"
|
|
CRDBPort: envutil.GetEnvInt("CRDB_PORT", 26257),
|
|
CRDBUser: envutil.GetEnv("CRDB_USER", "root"),
|
|
CRDBSSLMode: envutil.GetEnv("CRDB_SSL_MODE", "disable"),
|
|
RedisHost: os.Getenv("REDIS_HOST"), // e.g., "redis.databases.svc.cluster.local"
|
|
RedisPort: envutil.GetEnvInt("REDIS_PORT", 6379),
|
|
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
|
}
|
|
|
|
// Log which credentials were loaded from store vs env
|
|
fromStore := 0
|
|
for k := range creds {
|
|
if creds[k] != "" {
|
|
fromStore++
|
|
}
|
|
}
|
|
if fromStore > 0 {
|
|
logger.Info("loaded credentials from store", "count", fromStore)
|
|
}
|
|
|
|
return infraCfg
|
|
}
|
|
|
|
// closeProvisioner attempts to close a provisioner that implements io.Closer.
|
|
func closeProvisioner(p any, name string, logger *slog.Logger) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
if closer, ok := p.(interface{ Close() error }); ok {
|
|
if err := closer.Close(); err != nil {
|
|
logger.Warn("failed to close "+name+" provisioner", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// podGitCommitterAdapter wraps worker.PodGitOperations to satisfy
|
|
// service.PodGitCommitter without creating an import cycle.
|
|
type podGitCommitterAdapter struct {
|
|
podGitOps *worker.PodGitOperations
|
|
}
|
|
|
|
func (a *podGitCommitterAdapter) CommitAndPush(ctx context.Context, podName, workDir, message string, push bool) *service.GitCommitResult {
|
|
result := a.podGitOps.CommitAndPush(ctx, podName, workDir, message, push)
|
|
return &service.GitCommitResult{
|
|
HasChanges: result.HasChanges,
|
|
CommitSHA: result.CommitSHA,
|
|
FilesChanged: result.FilesChanged,
|
|
Pushed: result.Pushed,
|
|
Error: result.Error,
|
|
}
|
|
}
|