blob: da7528412f6b771bcb92cd46120b48c7a548a7a5 [file] [log] [blame]
iomodo8acd08d2025-07-31 16:22:08 +04001package commands
2
3import (
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
18var webhookCmd = &cobra.Command{
19 Use: "webhook",
20 Short: "Webhook management commands",
21 Long: `Manage GitHub webhooks for the Staff AI system.
22
23Webhooks are used to automatically detect when Pull Requests are merged
24and complete corresponding tasks in the Staff system.`,
25}
26
27var 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
32This command will:
331. Generate a webhook secret if not already configured
342. Register a webhook on GitHub for pull_request events
353. Update the config.yaml file with the webhook secret
36
37Examples:
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
45var (
46 webhookURL string
47 dryRun bool
48 force bool
49)
50
51func 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")
iomodo13a10fc2025-07-31 17:47:06 +040055
iomodo8acd08d2025-07-31 16:22:08 +040056 webhookCmd.AddCommand(webhookConfigureCmd)
57 rootCmd.AddCommand(webhookCmd)
58}
59
60func runWebhookConfigure(cmd *cobra.Command, args []string) error {
61 ctx := context.Background()
iomodo13a10fc2025-07-31 17:47:06 +040062
iomodo8acd08d2025-07-31 16:22:08 +040063 // 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 }
iomodo13a10fc2025-07-31 17:47:06 +0400128
iomodo8acd08d2025-07-31 16:22:08 +0400129 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 }
iomodo13a10fc2025-07-31 17:47:06 +0400134
iomodo8acd08d2025-07-31 16:22:08 +0400135 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 }
iomodo13a10fc2025-07-31 17:47:06 +0400146
iomodo8acd08d2025-07-31 16:22:08 +0400147 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")
iomodo13a10fc2025-07-31 17:47:06 +0400167
iomodo8acd08d2025-07-31 16:22:08 +0400168 return nil
169}
170
171func promptWebhookURL() (string, error) {
172 reader := bufio.NewReader(os.Stdin)
iomodo13a10fc2025-07-31 17:47:06 +0400173
iomodo8acd08d2025-07-31 16:22:08 +0400174 // 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 }
iomodo13a10fc2025-07-31 17:47:06 +0400180
iomodo8acd08d2025-07-31 16:22:08 +0400181 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 }
iomodo13a10fc2025-07-31 17:47:06 +0400186
iomodo8acd08d2025-07-31 16:22:08 +0400187 return strings.TrimSpace(url), nil
188}
189
190func validateWebhookURL(webhookURL string) error {
191 parsedURL, err := url.Parse(webhookURL)
192 if err != nil {
193 return fmt.Errorf("invalid URL format: %w", err)
194 }
iomodo13a10fc2025-07-31 17:47:06 +0400195
iomodo8acd08d2025-07-31 16:22:08 +0400196 if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
197 return fmt.Errorf("URL must use http or https scheme")
198 }
iomodo13a10fc2025-07-31 17:47:06 +0400199
iomodo8acd08d2025-07-31 16:22:08 +0400200 if parsedURL.Host == "" {
201 return fmt.Errorf("URL must include a host")
202 }
iomodo13a10fc2025-07-31 17:47:06 +0400203
iomodo8acd08d2025-07-31 16:22:08 +0400204 return nil
205}
206
207func 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 }
iomodo13a10fc2025-07-31 17:47:06 +0400213
iomodo8acd08d2025-07-31 16:22:08 +0400214 // Encode as hex string
215 return hex.EncodeToString(bytes), nil
216}
217
218func 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 }
iomodo13a10fc2025-07-31 17:47:06 +0400224
iomodo8acd08d2025-07-31 16:22:08 +0400225 // 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 }
iomodo13a10fc2025-07-31 17:47:06 +0400230
iomodo8acd08d2025-07-31 16:22:08 +0400231 // 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
iomodo13a10fc2025-07-31 17:47:06 +0400238
iomodo8acd08d2025-07-31 16:22:08 +0400239 // 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 }
iomodo13a10fc2025-07-31 17:47:06 +0400244
iomodo8acd08d2025-07-31 16:22:08 +0400245 // 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 }
iomodo13a10fc2025-07-31 17:47:06 +0400249
iomodo8acd08d2025-07-31 16:22:08 +0400250 return nil
iomodo13a10fc2025-07-31 17:47:06 +0400251}