| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 1 | package commands |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "crypto/rand" |
| 7 | "encoding/hex" |
| 8 | "fmt" |
| 9 | "net/url" |
| 10 | "os" |
| 11 | "strings" |
| 12 | |
| 13 | "github.com/iomodo/staff/git" |
| 14 | "github.com/spf13/cobra" |
| 15 | "gopkg.in/yaml.v3" |
| 16 | ) |
| 17 | |
| 18 | var webhookCmd = &cobra.Command{ |
| 19 | Use: "webhook", |
| 20 | Short: "Webhook management commands", |
| 21 | Long: `Manage GitHub webhooks for the Staff AI system. |
| 22 | |
| 23 | Webhooks are used to automatically detect when Pull Requests are merged |
| 24 | and complete corresponding tasks in the Staff system.`, |
| 25 | } |
| 26 | |
| 27 | var webhookConfigureCmd = &cobra.Command{ |
| 28 | Use: "configure", |
| 29 | Short: "Configure GitHub webhook for PR merge events", |
| 30 | Long: `Configure a GitHub webhook to automatically detect when Pull Requests are merged. |
| 31 | |
| 32 | This command will: |
| 33 | 1. Generate a webhook secret if not already configured |
| 34 | 2. Register a webhook on GitHub for pull_request events |
| 35 | 3. Update the config.yaml file with the webhook secret |
| 36 | |
| 37 | Examples: |
| 38 | staff webhook configure # Interactive setup |
| 39 | staff webhook configure --webhook-url https://example.com/webhooks/github |
| 40 | staff webhook configure --dry-run # Show what will be done |
| 41 | staff webhook configure --force # Force re-registration`, |
| 42 | RunE: runWebhookConfigure, |
| 43 | } |
| 44 | |
| 45 | var ( |
| 46 | webhookURL string |
| 47 | dryRun bool |
| 48 | force bool |
| 49 | ) |
| 50 | |
| 51 | func init() { |
| 52 | webhookConfigureCmd.Flags().StringVar(&webhookURL, "webhook-url", "", "Webhook URL (if not provided, will prompt)") |
| 53 | webhookConfigureCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be done without executing") |
| 54 | webhookConfigureCmd.Flags().BoolVar(&force, "force", false, "Force re-registration even if webhook exists") |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 55 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 56 | webhookCmd.AddCommand(webhookConfigureCmd) |
| 57 | rootCmd.AddCommand(webhookCmd) |
| 58 | } |
| 59 | |
| 60 | func runWebhookConfigure(cmd *cobra.Command, args []string) error { |
| 61 | ctx := context.Background() |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 62 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 63 | // Validate GitHub configuration |
| 64 | if !cfg.HasGitHubConfig() { |
| 65 | return fmt.Errorf("GitHub configuration is required. Please configure github.token, github.owner, and github.repo in config.yaml") |
| 66 | } |
| 67 | |
| 68 | // Get webhook URL if not provided |
| 69 | if webhookURL == "" { |
| 70 | var err error |
| 71 | webhookURL, err = promptWebhookURL() |
| 72 | if err != nil { |
| 73 | return fmt.Errorf("failed to get webhook URL: %w", err) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // Validate webhook URL |
| 78 | if err := validateWebhookURL(webhookURL); err != nil { |
| 79 | return fmt.Errorf("invalid webhook URL: %w", err) |
| 80 | } |
| 81 | |
| 82 | // Generate webhook secret if not configured |
| 83 | secret := cfg.GitHub.WebhookSecret |
| 84 | if secret == "" { |
| 85 | var err error |
| 86 | secret, err = generateWebhookSecret() |
| 87 | if err != nil { |
| 88 | return fmt.Errorf("failed to generate webhook secret: %w", err) |
| 89 | } |
| 90 | fmt.Printf("Generated new webhook secret: %s\n", secret) |
| 91 | } else { |
| 92 | fmt.Printf("Using existing webhook secret: %s\n", secret) |
| 93 | } |
| 94 | |
| 95 | if dryRun { |
| 96 | fmt.Printf("\nDry run mode - showing what would be done:\n") |
| 97 | fmt.Printf("- Webhook URL: %s\n", webhookURL) |
| 98 | fmt.Printf("- Events: [pull_request]\n") |
| 99 | fmt.Printf("- Secret: %s\n", secret) |
| 100 | fmt.Printf("- Repository: %s/%s\n", cfg.GitHub.Owner, cfg.GitHub.Repo) |
| 101 | if cfg.GitHub.WebhookSecret == "" { |
| 102 | fmt.Printf("- Would update config.yaml with webhook secret\n") |
| 103 | } |
| 104 | fmt.Printf("- Would register webhook on GitHub\n") |
| 105 | return nil |
| 106 | } |
| 107 | |
| 108 | // Check existing webhooks |
| 109 | fmt.Printf("Checking existing webhooks...\n") |
| 110 | webhooks, err := gitInterface.ListWebhooks(ctx) |
| 111 | if err != nil { |
| 112 | return fmt.Errorf("failed to list existing webhooks: %w", err) |
| 113 | } |
| 114 | |
| 115 | // Look for existing webhook with our URL |
| 116 | var existingWebhook *git.GitHubWebhookResponse |
| 117 | for i, webhook := range webhooks { |
| 118 | if webhook.Config.URL == webhookURL && webhook.Name == "web" { |
| 119 | existingWebhook = &webhooks[i] |
| 120 | break |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | if existingWebhook != nil { |
| 125 | if !force { |
| 126 | return fmt.Errorf("webhook already exists with URL %s (ID: %d). Use --force to update it", webhookURL, existingWebhook.ID) |
| 127 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 128 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 129 | fmt.Printf("Updating existing webhook (ID: %d)...\n", existingWebhook.ID) |
| 130 | updatedWebhook, err := gitInterface.UpdateWebhook(ctx, existingWebhook.ID, webhookURL, secret) |
| 131 | if err != nil { |
| 132 | return fmt.Errorf("failed to update webhook: %w", err) |
| 133 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 134 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 135 | fmt.Printf("Successfully updated webhook!\n") |
| 136 | fmt.Printf("- Webhook ID: %d\n", updatedWebhook.ID) |
| 137 | fmt.Printf("- URL: %s\n", updatedWebhook.Config.URL) |
| 138 | fmt.Printf("- Events: %v\n", updatedWebhook.Events) |
| 139 | fmt.Printf("- Secret: %s\n", secret) |
| 140 | } else { |
| 141 | fmt.Printf("Creating new webhook...\n") |
| 142 | newWebhook, err := gitInterface.CreateWebhook(ctx, webhookURL, secret) |
| 143 | if err != nil { |
| 144 | return fmt.Errorf("failed to create webhook: %w", err) |
| 145 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 146 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 147 | fmt.Printf("Successfully created webhook!\n") |
| 148 | fmt.Printf("- Webhook ID: %d\n", newWebhook.ID) |
| 149 | fmt.Printf("- URL: %s\n", newWebhook.Config.URL) |
| 150 | fmt.Printf("- Events: %v\n", newWebhook.Events) |
| 151 | fmt.Printf("- Secret: %s\n", secret) |
| 152 | } |
| 153 | |
| 154 | // Update config file if webhook secret changed |
| 155 | if cfg.GitHub.WebhookSecret != secret { |
| 156 | fmt.Printf("Updating config.yaml with webhook secret...\n") |
| 157 | if err := updateConfigWebhookSecret(secret); err != nil { |
| 158 | return fmt.Errorf("failed to update config file: %w", err) |
| 159 | } |
| 160 | fmt.Printf("Config file updated successfully!\n") |
| 161 | } |
| 162 | |
| 163 | fmt.Printf("\nWebhook configuration complete!\n") |
| 164 | fmt.Printf("\nWebhook Secret: %s\n", secret) |
| 165 | fmt.Printf("Please save this secret for your webhook endpoint configuration.\n") |
| 166 | fmt.Printf("\nYour Staff system will now automatically detect merged PRs and complete tasks.\n") |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 167 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 168 | return nil |
| 169 | } |
| 170 | |
| 171 | func promptWebhookURL() (string, error) { |
| 172 | reader := bufio.NewReader(os.Stdin) |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 173 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 174 | // Show server listen address if configured |
| 175 | if cfg.Server.ListenAddress != "" { |
| 176 | fmt.Printf("Detected server listen address: %s\n", cfg.Server.ListenAddress) |
| 177 | fmt.Printf("Suggested webhook URL: http://%s/webhooks/github\n", cfg.Server.ListenAddress) |
| 178 | fmt.Printf("(Note: Use https:// and a public domain for production)\n") |
| 179 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 180 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 181 | fmt.Printf("Enter webhook URL (e.g., https://your-domain.com/webhooks/github): ") |
| 182 | url, err := reader.ReadString('\n') |
| 183 | if err != nil { |
| 184 | return "", err |
| 185 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 186 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 187 | return strings.TrimSpace(url), nil |
| 188 | } |
| 189 | |
| 190 | func validateWebhookURL(webhookURL string) error { |
| 191 | parsedURL, err := url.Parse(webhookURL) |
| 192 | if err != nil { |
| 193 | return fmt.Errorf("invalid URL format: %w", err) |
| 194 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 195 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 196 | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { |
| 197 | return fmt.Errorf("URL must use http or https scheme") |
| 198 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 199 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 200 | if parsedURL.Host == "" { |
| 201 | return fmt.Errorf("URL must include a host") |
| 202 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 203 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 204 | return nil |
| 205 | } |
| 206 | |
| 207 | func generateWebhookSecret() (string, error) { |
| 208 | // Generate 32 bytes of random data |
| 209 | bytes := make([]byte, 32) |
| 210 | if _, err := rand.Read(bytes); err != nil { |
| 211 | return "", fmt.Errorf("failed to generate random bytes: %w", err) |
| 212 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 213 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 214 | // Encode as hex string |
| 215 | return hex.EncodeToString(bytes), nil |
| 216 | } |
| 217 | |
| 218 | func updateConfigWebhookSecret(secret string) error { |
| 219 | // Read current config file |
| 220 | configData, err := os.ReadFile("config.yaml") |
| 221 | if err != nil { |
| 222 | return fmt.Errorf("failed to read config file: %w", err) |
| 223 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 224 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 225 | // Parse YAML |
| 226 | var configMap map[string]interface{} |
| 227 | if err := yaml.Unmarshal(configData, &configMap); err != nil { |
| 228 | return fmt.Errorf("failed to parse config YAML: %w", err) |
| 229 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 230 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 231 | // Update webhook secret |
| 232 | github, ok := configMap["github"].(map[string]interface{}) |
| 233 | if !ok { |
| 234 | github = make(map[string]interface{}) |
| 235 | configMap["github"] = github |
| 236 | } |
| 237 | github["webhook_secret"] = secret |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 238 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 239 | // Marshal back to YAML |
| 240 | updatedData, err := yaml.Marshal(configMap) |
| 241 | if err != nil { |
| 242 | return fmt.Errorf("failed to marshal config YAML: %w", err) |
| 243 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 244 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 245 | // Write back to file |
| 246 | if err := os.WriteFile("config.yaml", updatedData, 0644); err != nil { |
| 247 | return fmt.Errorf("failed to write config file: %w", err) |
| 248 | } |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 249 | |
| iomodo | 8acd08d | 2025-07-31 16:22:08 +0400 | [diff] [blame] | 250 | return nil |
| iomodo | 13a10fc | 2025-07-31 17:47:06 +0400 | [diff] [blame] | 251 | } |