Add Github wehbook management
Change-Id: I4b7a23a77838f345d65adf51877fab5978bd6055
diff --git a/server/app/proposal.go b/server/app/proposal.go
index 4b2acc4..165f336 100644
--- a/server/app/proposal.go
+++ b/server/app/proposal.go
@@ -15,7 +15,7 @@
// Process the webhook payload
taskID, err := git.ProcessMergeWebhook(body, signature, a.config.GitHub.WebhookSecret)
if err != nil {
- return fmt.Errorf("Failed to process webhook: %w", err)
+ return fmt.Errorf("failed to process webhook: %w", err)
}
// Log the successful approval
diff --git a/server/cmd/commands/root.go b/server/cmd/commands/root.go
index e75e134..7a4de91 100644
--- a/server/cmd/commands/root.go
+++ b/server/cmd/commands/root.go
@@ -21,6 +21,7 @@
agentManager *agent.Manager
taskManager tm.TaskManager
cfg *config.Config
+ gitInterface git.GitInterface
)
// Run function starts the application
@@ -75,7 +76,7 @@
Level: slog.LevelInfo,
}))
- gitInterface := git.New(cfg, logger)
+ gitInterface = git.New(cfg, logger)
taskManager = git_tm.NewGitTaskManager(gitInterface, cfg, logger)
// Initialize agent manager
diff --git a/server/cmd/commands/webhook.go b/server/cmd/commands/webhook.go
new file mode 100644
index 0000000..e62d1ce
--- /dev/null
+++ b/server/cmd/commands/webhook.go
@@ -0,0 +1,251 @@
+package commands
+
+import (
+ "bufio"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "net/url"
+ "os"
+ "strings"
+
+ "github.com/iomodo/staff/git"
+ "github.com/spf13/cobra"
+ "gopkg.in/yaml.v3"
+)
+
+var webhookCmd = &cobra.Command{
+ Use: "webhook",
+ Short: "Webhook management commands",
+ Long: `Manage GitHub webhooks for the Staff AI system.
+
+Webhooks are used to automatically detect when Pull Requests are merged
+and complete corresponding tasks in the Staff system.`,
+}
+
+var webhookConfigureCmd = &cobra.Command{
+ Use: "configure",
+ Short: "Configure GitHub webhook for PR merge events",
+ Long: `Configure a GitHub webhook to automatically detect when Pull Requests are merged.
+
+This command will:
+1. Generate a webhook secret if not already configured
+2. Register a webhook on GitHub for pull_request events
+3. Update the config.yaml file with the webhook secret
+
+Examples:
+ staff webhook configure # Interactive setup
+ staff webhook configure --webhook-url https://example.com/webhooks/github
+ staff webhook configure --dry-run # Show what will be done
+ staff webhook configure --force # Force re-registration`,
+ RunE: runWebhookConfigure,
+}
+
+var (
+ webhookURL string
+ dryRun bool
+ force bool
+)
+
+func init() {
+ webhookConfigureCmd.Flags().StringVar(&webhookURL, "webhook-url", "", "Webhook URL (if not provided, will prompt)")
+ webhookConfigureCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be done without executing")
+ webhookConfigureCmd.Flags().BoolVar(&force, "force", false, "Force re-registration even if webhook exists")
+
+ webhookCmd.AddCommand(webhookConfigureCmd)
+ rootCmd.AddCommand(webhookCmd)
+}
+
+func runWebhookConfigure(cmd *cobra.Command, args []string) error {
+ ctx := context.Background()
+
+ // Validate GitHub configuration
+ if !cfg.HasGitHubConfig() {
+ return fmt.Errorf("GitHub configuration is required. Please configure github.token, github.owner, and github.repo in config.yaml")
+ }
+
+ // Get webhook URL if not provided
+ if webhookURL == "" {
+ var err error
+ webhookURL, err = promptWebhookURL()
+ if err != nil {
+ return fmt.Errorf("failed to get webhook URL: %w", err)
+ }
+ }
+
+ // Validate webhook URL
+ if err := validateWebhookURL(webhookURL); err != nil {
+ return fmt.Errorf("invalid webhook URL: %w", err)
+ }
+
+ // Generate webhook secret if not configured
+ secret := cfg.GitHub.WebhookSecret
+ if secret == "" {
+ var err error
+ secret, err = generateWebhookSecret()
+ if err != nil {
+ return fmt.Errorf("failed to generate webhook secret: %w", err)
+ }
+ fmt.Printf("Generated new webhook secret: %s\n", secret)
+ } else {
+ fmt.Printf("Using existing webhook secret: %s\n", secret)
+ }
+
+ if dryRun {
+ fmt.Printf("\nDry run mode - showing what would be done:\n")
+ fmt.Printf("- Webhook URL: %s\n", webhookURL)
+ fmt.Printf("- Events: [pull_request]\n")
+ fmt.Printf("- Secret: %s\n", secret)
+ fmt.Printf("- Repository: %s/%s\n", cfg.GitHub.Owner, cfg.GitHub.Repo)
+ if cfg.GitHub.WebhookSecret == "" {
+ fmt.Printf("- Would update config.yaml with webhook secret\n")
+ }
+ fmt.Printf("- Would register webhook on GitHub\n")
+ return nil
+ }
+
+ // Check existing webhooks
+ fmt.Printf("Checking existing webhooks...\n")
+ webhooks, err := gitInterface.ListWebhooks(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to list existing webhooks: %w", err)
+ }
+
+ // Look for existing webhook with our URL
+ var existingWebhook *git.GitHubWebhookResponse
+ for i, webhook := range webhooks {
+ if webhook.Config.URL == webhookURL && webhook.Name == "web" {
+ existingWebhook = &webhooks[i]
+ break
+ }
+ }
+
+ if existingWebhook != nil {
+ if !force {
+ return fmt.Errorf("webhook already exists with URL %s (ID: %d). Use --force to update it", webhookURL, existingWebhook.ID)
+ }
+
+ fmt.Printf("Updating existing webhook (ID: %d)...\n", existingWebhook.ID)
+ updatedWebhook, err := gitInterface.UpdateWebhook(ctx, existingWebhook.ID, webhookURL, secret)
+ if err != nil {
+ return fmt.Errorf("failed to update webhook: %w", err)
+ }
+
+ fmt.Printf("Successfully updated webhook!\n")
+ fmt.Printf("- Webhook ID: %d\n", updatedWebhook.ID)
+ fmt.Printf("- URL: %s\n", updatedWebhook.Config.URL)
+ fmt.Printf("- Events: %v\n", updatedWebhook.Events)
+ fmt.Printf("- Secret: %s\n", secret)
+ } else {
+ fmt.Printf("Creating new webhook...\n")
+ newWebhook, err := gitInterface.CreateWebhook(ctx, webhookURL, secret)
+ if err != nil {
+ return fmt.Errorf("failed to create webhook: %w", err)
+ }
+
+ fmt.Printf("Successfully created webhook!\n")
+ fmt.Printf("- Webhook ID: %d\n", newWebhook.ID)
+ fmt.Printf("- URL: %s\n", newWebhook.Config.URL)
+ fmt.Printf("- Events: %v\n", newWebhook.Events)
+ fmt.Printf("- Secret: %s\n", secret)
+ }
+
+ // Update config file if webhook secret changed
+ if cfg.GitHub.WebhookSecret != secret {
+ fmt.Printf("Updating config.yaml with webhook secret...\n")
+ if err := updateConfigWebhookSecret(secret); err != nil {
+ return fmt.Errorf("failed to update config file: %w", err)
+ }
+ fmt.Printf("Config file updated successfully!\n")
+ }
+
+ fmt.Printf("\nWebhook configuration complete!\n")
+ fmt.Printf("\nWebhook Secret: %s\n", secret)
+ fmt.Printf("Please save this secret for your webhook endpoint configuration.\n")
+ fmt.Printf("\nYour Staff system will now automatically detect merged PRs and complete tasks.\n")
+
+ return nil
+}
+
+func promptWebhookURL() (string, error) {
+ reader := bufio.NewReader(os.Stdin)
+
+ // Show server listen address if configured
+ if cfg.Server.ListenAddress != "" {
+ fmt.Printf("Detected server listen address: %s\n", cfg.Server.ListenAddress)
+ fmt.Printf("Suggested webhook URL: http://%s/webhooks/github\n", cfg.Server.ListenAddress)
+ fmt.Printf("(Note: Use https:// and a public domain for production)\n")
+ }
+
+ fmt.Printf("Enter webhook URL (e.g., https://your-domain.com/webhooks/github): ")
+ url, err := reader.ReadString('\n')
+ if err != nil {
+ return "", err
+ }
+
+ return strings.TrimSpace(url), nil
+}
+
+func validateWebhookURL(webhookURL string) error {
+ parsedURL, err := url.Parse(webhookURL)
+ if err != nil {
+ return fmt.Errorf("invalid URL format: %w", err)
+ }
+
+ if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
+ return fmt.Errorf("URL must use http or https scheme")
+ }
+
+ if parsedURL.Host == "" {
+ return fmt.Errorf("URL must include a host")
+ }
+
+ return nil
+}
+
+func generateWebhookSecret() (string, error) {
+ // Generate 32 bytes of random data
+ bytes := make([]byte, 32)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", fmt.Errorf("failed to generate random bytes: %w", err)
+ }
+
+ // Encode as hex string
+ return hex.EncodeToString(bytes), nil
+}
+
+func updateConfigWebhookSecret(secret string) error {
+ // Read current config file
+ configData, err := os.ReadFile("config.yaml")
+ if err != nil {
+ return fmt.Errorf("failed to read config file: %w", err)
+ }
+
+ // Parse YAML
+ var configMap map[string]interface{}
+ if err := yaml.Unmarshal(configData, &configMap); err != nil {
+ return fmt.Errorf("failed to parse config YAML: %w", err)
+ }
+
+ // Update webhook secret
+ github, ok := configMap["github"].(map[string]interface{})
+ if !ok {
+ github = make(map[string]interface{})
+ configMap["github"] = github
+ }
+ github["webhook_secret"] = secret
+
+ // Marshal back to YAML
+ updatedData, err := yaml.Marshal(configMap)
+ if err != nil {
+ return fmt.Errorf("failed to marshal config YAML: %w", err)
+ }
+
+ // Write back to file
+ if err := os.WriteFile("config.yaml", updatedData, 0644); err != nil {
+ return fmt.Errorf("failed to write config file: %w", err)
+ }
+
+ return nil
+}
\ No newline at end of file
diff --git a/server/config.yaml b/server/config.yaml
index 5ce3fec..dabe621 100644
--- a/server/config.yaml
+++ b/server/config.yaml
@@ -2,7 +2,7 @@
# This is a minimal config for testing the MVP
server:
- listen_address: ":9325"
+ listen_address: "9325"
openai:
api_key: "${OPENAI_API_KEY}"
diff --git a/server/git/git.go b/server/git/git.go
index 4cd7508..29cee16 100644
--- a/server/git/git.go
+++ b/server/git/git.go
@@ -69,6 +69,11 @@
RefreshAgentClone(agentName string) error
CleanupAgentClone(agentName string) error
CleanupAllClones() error
+
+ // Webhook operations (GitHub only)
+ ListWebhooks(ctx context.Context) ([]GitHubWebhookResponse, error)
+ CreateWebhook(ctx context.Context, webhookURL, secret string) (*GitHubWebhookResponse, error)
+ UpdateWebhook(ctx context.Context, webhookID int, webhookURL, secret string) (*GitHubWebhookResponse, error)
}
// Status represents the current state of the repository
@@ -679,6 +684,35 @@
return g.cloneManager.CleanupAllClones()
}
+// Webhook operations (GitHub only)
+
+// ListWebhooks lists existing webhooks for the repository
+func (g *Git) ListWebhooks(ctx context.Context) ([]GitHubWebhookResponse, error) {
+ webhookProvider, ok := g.prProvider.(WebhookProvider)
+ if !ok {
+ return nil, &GitError{Command: "ListWebhooks", Output: "webhook operations not supported by current provider"}
+ }
+ return webhookProvider.ListWebhooks(ctx)
+}
+
+// CreateWebhook creates a new webhook for the repository
+func (g *Git) CreateWebhook(ctx context.Context, webhookURL, secret string) (*GitHubWebhookResponse, error) {
+ webhookProvider, ok := g.prProvider.(WebhookProvider)
+ if !ok {
+ return nil, &GitError{Command: "CreateWebhook", Output: "webhook operations not supported by current provider"}
+ }
+ return webhookProvider.CreateWebhook(ctx, webhookURL, secret)
+}
+
+// UpdateWebhook updates an existing webhook
+func (g *Git) UpdateWebhook(ctx context.Context, webhookID int, webhookURL, secret string) (*GitHubWebhookResponse, error) {
+ webhookProvider, ok := g.prProvider.(WebhookProvider)
+ if !ok {
+ return nil, &GitError{Command: "UpdateWebhook", Output: "webhook operations not supported by current provider"}
+ }
+ return webhookProvider.UpdateWebhook(ctx, webhookID, webhookURL, secret)
+}
+
// Helper methods
func (g *Git) runCommand(cmd *exec.Cmd, command string) error {
@@ -982,3 +1016,10 @@
ClosePullRequest(ctx context.Context, id string) error
MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error
}
+
+// WebhookProvider defines the interface for webhook operations
+type WebhookProvider interface {
+ ListWebhooks(ctx context.Context) ([]GitHubWebhookResponse, error)
+ CreateWebhook(ctx context.Context, webhookURL, secret string) (*GitHubWebhookResponse, error)
+ UpdateWebhook(ctx context.Context, webhookID int, webhookURL, secret string) (*GitHubWebhookResponse, error)
+}
diff --git a/server/git/github.go b/server/git/github.go
index 1d37b1d..a73c4c6 100644
--- a/server/git/github.go
+++ b/server/git/github.go
@@ -112,6 +112,29 @@
MergeMethod string `json:"merge_method,omitempty"`
}
+// GitHub webhook API types
+type githubWebhookRequest struct {
+ Name string `json:"name"`
+ Active bool `json:"active"`
+ Events []string `json:"events"`
+ Config githubWebhookConfig `json:"config"`
+}
+
+type githubWebhookConfig struct {
+ URL string `json:"url"`
+ ContentType string `json:"content_type"`
+ Secret string `json:"secret"`
+}
+
+type GitHubWebhookResponse struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Active bool `json:"active"`
+ Events []string `json:"events"`
+ Config githubWebhookConfig `json:"config"`
+ URL string `json:"url"`
+}
+
// CreatePullRequest creates a new pull request on GitHub
func (g *GitHubPullRequestProvider) CreatePullRequest(ctx context.Context, options PullRequestOptions) (*PullRequest, error) {
reqBody := githubCreatePRRequest{
@@ -415,3 +438,140 @@
URL: fmt.Sprintf("https://github.com/%s/%s/pull/%d", g.owner, g.repo, githubPR.Number),
}
}
+
+// ListWebhooks lists existing webhooks for the repository
+func (g *GitHubPullRequestProvider) ListWebhooks(ctx context.Context) ([]GitHubWebhookResponse, error) {
+ url := fmt.Sprintf("%s/repos/%s/%s/hooks", g.config.BaseURL, g.owner, g.repo)
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "token "+g.config.Token)
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+ resp, err := g.config.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ var errorBody bytes.Buffer
+ _, _ = errorBody.ReadFrom(resp.Body)
+ return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, errorBody.String())
+ }
+
+ var webhooks []GitHubWebhookResponse
+ if err := json.NewDecoder(resp.Body).Decode(&webhooks); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return webhooks, nil
+}
+
+// CreateWebhook creates a new webhook for the repository
+func (g *GitHubPullRequestProvider) CreateWebhook(ctx context.Context, webhookURL, secret string) (*GitHubWebhookResponse, error) {
+ reqBody := githubWebhookRequest{
+ Name: "web",
+ Active: true,
+ Events: []string{"pull_request"},
+ Config: githubWebhookConfig{
+ URL: webhookURL,
+ ContentType: "json",
+ Secret: secret,
+ },
+ }
+
+ jsonBody, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
+ }
+
+ g.logger.Info("Creating GitHub webhook",
+ slog.String("url", fmt.Sprintf("%s/repos/%s/%s/hooks", g.config.BaseURL, g.owner, g.repo)),
+ slog.String("webhook_url", webhookURL),
+ slog.Any("events", reqBody.Events))
+
+ url := fmt.Sprintf("%s/repos/%s/%s/hooks", g.config.BaseURL, g.owner, g.repo)
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "token "+g.config.Token)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+ resp, err := g.config.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusCreated {
+ var errorBody bytes.Buffer
+ _, _ = errorBody.ReadFrom(resp.Body)
+ return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, errorBody.String())
+ }
+
+ var webhook GitHubWebhookResponse
+ if err := json.NewDecoder(resp.Body).Decode(&webhook); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &webhook, nil
+}
+
+// UpdateWebhook updates an existing webhook
+func (g *GitHubPullRequestProvider) UpdateWebhook(ctx context.Context, webhookID int, webhookURL, secret string) (*GitHubWebhookResponse, error) {
+ reqBody := githubWebhookRequest{
+ Name: "web",
+ Active: true,
+ Events: []string{"pull_request"},
+ Config: githubWebhookConfig{
+ URL: webhookURL,
+ ContentType: "json",
+ Secret: secret,
+ },
+ }
+
+ jsonBody, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
+ }
+
+ g.logger.Info("Updating GitHub webhook",
+ slog.Int("webhook_id", webhookID),
+ slog.String("webhook_url", webhookURL),
+ slog.Any("events", reqBody.Events))
+
+ url := fmt.Sprintf("%s/repos/%s/%s/hooks/%d", g.config.BaseURL, g.owner, g.repo, webhookID)
+ req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewBuffer(jsonBody))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "token "+g.config.Token)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+ resp, err := g.config.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ var errorBody bytes.Buffer
+ _, _ = errorBody.ReadFrom(resp.Body)
+ return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, errorBody.String())
+ }
+
+ var webhook GitHubWebhookResponse
+ if err := json.NewDecoder(resp.Body).Decode(&webhook); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return &webhook, nil
+}
diff --git a/server/staff b/server/staff
index cfd99cf..22c9770 100755
--- a/server/staff
+++ b/server/staff
Binary files differ
diff --git a/server/tm/git_tm/git_task_manager.go b/server/tm/git_tm/git_task_manager.go
index 2e9fce3..a49a866 100644
--- a/server/tm/git_tm/git_task_manager.go
+++ b/server/tm/git_tm/git_task_manager.go
@@ -1103,7 +1103,7 @@
cleanTitle = cleanTitle[:40]
}
- return fmt.Sprintf("%s%s-%s", prefix, task.ID, cleanTitle)
+ return fmt.Sprintf("%s/%s-%s", prefix, task.ID, cleanTitle)
}
// buildSolutionPRDescription creates PR description from template