Add Github Webhook data
Change-Id: I69417685de1264b10f60fc4c74e290890cdb0a67
diff --git a/CLAUDE.md b/CLAUDE.md
index 99dbb5d..522115d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -30,35 +30,26 @@
### Building and Running
```bash
-# Build the application
-cd server && go build -o staff ./cmd/main.go
+# Run the application
+cd server && go run cmd/main.go server
-# Run with specific commands
-./staff [command] [args]
-# Run tests
-go test ./server/...
-# Run specific package tests
-go test ./server/agent/...
-go test ./server/llm/...
-go test ./server/tm/...
-```
+### Testing
+```bash
+# Run all tests
+cd server && go test ./...
+
+# Run with coverage
+cd server && go test -cover ./...
### Common Development Tasks
-
```bash
-# Format code
-go fmt ./...
-
# Check for linting issues
-go vet ./...
+cd server && go vet ./...
# Update dependencies
-go mod tidy
-
-# View available commands
-./staff --help
+cd server && go mod tidy
```
## Agent System Architecture
@@ -89,7 +80,8 @@
- xAI (Grok models)
- Claude (Anthropic)
- Gemini (Google)
-- Local models
+- Local models (via Ollama)
+- Fake provider (for testing)
### Provider Interface
All providers implement the same interface:
@@ -103,10 +95,15 @@
- Timeout and retry settings
- Provider-specific extra parameters
+Configuration can be provided via:
+- `config.yaml` file in server directory
+- Environment variables (OPENAI_API_KEY, GITHUB_TOKEN, etc.)
+- Command-line configuration during setup
+
## File Structure Patterns
### Agent Definitions
-- Agent system prompts stored in `/operations/agents/{role}/system.md`
+- Agent system prompts stored in `/operations/agents/{name}/system.md`
- Each agent has detailed role definition and behavioral guidelines
### Task Files
@@ -115,23 +112,24 @@
- Include task metadata, description, and assignment info
### Solution PRs
-- Agents create branches: `task/{task-id}-{clean-title}`
+- Agents create branches: `solution/{task-id}-{clean-title}` or `subtasks/{task-id}-{clean-title}`
- Solutions formatted as markdown with task metadata
- Automated commit messages and PR descriptions
## Key Dependencies
### Go Modules
-- `github.com/spf13/cobra` - CLI framework
-- `github.com/google/uuid` - UUID generation
-- `github.com/stretchr/testify` - Testing framework
-- `golang.org/x/text` - Text processing
-- `gopkg.in/yaml.v3` - YAML parsing
+- `github.com/spf13/cobra` - CLI framework for command-line interface
+- `github.com/google/uuid` - UUID generation for task and agent IDs
+- `github.com/joho/godotenv` - Environment variable loading from .env files
+- `golang.org/x/text` - Text processing utilities
+- `gopkg.in/yaml.v3` - YAML parsing for configuration files
### Development Dependencies
-- Go 1.24.4+ required
+- Go 1.24.4+ required
- Git for version control and PR operations
-- Access to LLM provider APIs (OpenAI, etc.)
+- Access to LLM provider APIs (OpenAI, xAI, Claude, Gemini, etc.)
+- Environment variables or config.yaml for API keys and credentials
## Testing Strategy
@@ -142,19 +140,6 @@
- Task management operations
- Git operations and branch creation
-### Test Execution
-```bash
-# Run all tests
-go test ./server/...
-
-# Run with coverage
-go test -cover ./server/...
-
-# Run specific test suites
-go test ./server/agent/ -v
-go test ./server/llm/ -v
-go test ./server/tm/ -v
-```
## Security Considerations
@@ -166,9 +151,9 @@
## Integration Points
### External Systems
-- Git repositories for task management and code storage
+- Git repositories for task management and code storage (GitHub/Gerrit)
- LLM provider APIs for agent intelligence
-- Task management systems (GitHub Projects, Asana, Jira)
+- Task management systems via Git-based task tracking
### Internal Communication
- Agents communicate through task management system
diff --git a/server/git/webhook.go b/server/git/webhook.go
new file mode 100644
index 0000000..df12492
--- /dev/null
+++ b/server/git/webhook.go
@@ -0,0 +1,362 @@
+package git
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// GitHubWebhook represents the GitHub pull request webhook payload
+type GitHubWebhook struct {
+ Action string `json:"action"`
+ Number int `json:"number"`
+ PullRequest *GitHubPullRequest `json:"pull_request"`
+ Repository *Repository `json:"repository"`
+ Sender *User `json:"sender"`
+ Changes *Changes `json:"changes,omitempty"`
+}
+
+// GitHubPullRequest contains the pull request data from GitHub webhook
+type GitHubPullRequest struct {
+ ID int `json:"id"`
+ Number int `json:"number"`
+ State string `json:"state"`
+ Locked bool `json:"locked"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ ClosedAt *time.Time `json:"closed_at"`
+ MergedAt *time.Time `json:"merged_at"`
+ MergeCommitSHA *string `json:"merge_commit_sha"`
+ Assignee *User `json:"assignee"`
+ Assignees []*User `json:"assignees"`
+ RequestedReviewers []*User `json:"requested_reviewers"`
+ Labels []*Label `json:"labels"`
+ Milestone *Milestone `json:"milestone"`
+ Draft bool `json:"draft"`
+ Merged bool `json:"merged"`
+ Mergeable *bool `json:"mergeable"`
+ MergeableState string `json:"mergeable_state"`
+ MergedBy *User `json:"merged_by"`
+ Comments int `json:"comments"`
+ ReviewComments int `json:"review_comments"`
+ Commits int `json:"commits"`
+ Additions int `json:"additions"`
+ Deletions int `json:"deletions"`
+ ChangedFiles int `json:"changed_files"`
+ Head *PullRequestBranch `json:"head"`
+ Base *PullRequestBranch `json:"base"`
+ User *User `json:"user"`
+}
+
+// PullRequestBranch contains branch information
+type PullRequestBranch struct {
+ Label string `json:"label"`
+ Ref string `json:"ref"`
+ SHA string `json:"sha"`
+ User *User `json:"user"`
+ Repo *Repository `json:"repo"`
+}
+
+// Repository contains repository information
+type Repository struct {
+ ID int `json:"id"`
+ NodeID string `json:"node_id"`
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ Private bool `json:"private"`
+ Owner *User `json:"owner"`
+ Description *string `json:"description"`
+ Fork bool `json:"fork"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ PushedAt time.Time `json:"pushed_at"`
+ GitURL string `json:"git_url"`
+ SSHURL string `json:"ssh_url"`
+ CloneURL string `json:"clone_url"`
+ SvnURL string `json:"svn_url"`
+ Homepage *string `json:"homepage"`
+ Size int `json:"size"`
+ StargazersCount int `json:"stargazers_count"`
+ WatchersCount int `json:"watchers_count"`
+ Language *string `json:"language"`
+ HasIssues bool `json:"has_issues"`
+ HasProjects bool `json:"has_projects"`
+ HasDownloads bool `json:"has_downloads"`
+ HasWiki bool `json:"has_wiki"`
+ HasPages bool `json:"has_pages"`
+ HasDiscussions bool `json:"has_discussions"`
+ ForksCount int `json:"forks_count"`
+ Archived bool `json:"archived"`
+ Disabled bool `json:"disabled"`
+ License *License `json:"license"`
+ AllowForking bool `json:"allow_forking"`
+ IsTemplate bool `json:"is_template"`
+ WebCommitSignoffRequired bool `json:"web_commit_signoff_required"`
+ Topics []string `json:"topics"`
+ Visibility string `json:"visibility"`
+ DefaultBranch string `json:"default_branch"`
+ AllowSquashMerge bool `json:"allow_squash_merge"`
+ AllowMergeCommit bool `json:"allow_merge_commit"`
+ AllowRebaseMerge bool `json:"allow_rebase_merge"`
+ AllowAutoMerge bool `json:"allow_auto_merge"`
+ DeleteBranchOnMerge bool `json:"delete_branch_on_merge"`
+ AllowUpdateBranch bool `json:"allow_update_branch"`
+ UseSquashPrTitleAsDefault bool `json:"use_squash_pr_title_as_default"`
+ SquashMergeCommitMessage string `json:"squash_merge_commit_message"`
+ SquashMergeCommitTitle string `json:"squash_merge_commit_title"`
+ MergeCommitMessage string `json:"merge_commit_message"`
+ MergeCommitTitle string `json:"merge_commit_title"`
+ SecurityAndAnalysis *SecurityAndAnalysis `json:"security_and_analysis"`
+}
+
+// User contains user information
+type User struct {
+ Login string `json:"login"`
+ ID int `json:"id"`
+ NodeID string `json:"node_id"`
+ AvatarURL string `json:"avatar_url"`
+ GravatarID string `json:"gravatar_id"`
+ URL string `json:"url"`
+ HTMLURL string `json:"html_url"`
+ FollowersURL string `json:"followers_url"`
+ FollowingURL string `json:"following_url"`
+ GistsURL string `json:"gists_url"`
+ StarredURL string `json:"starred_url"`
+ SubscriptionsURL string `json:"subscriptions_url"`
+ OrganizationsURL string `json:"organizations_url"`
+ ReposURL string `json:"repos_url"`
+ EventsURL string `json:"events_url"`
+ ReceivedEventsURL string `json:"received_events_url"`
+ Type string `json:"type"`
+ SiteAdmin bool `json:"site_admin"`
+ Name *string `json:"name"`
+ Company *string `json:"company"`
+ Blog *string `json:"blog"`
+ Location *string `json:"location"`
+ Email *string `json:"email"`
+ Hireable *bool `json:"hireable"`
+ Bio *string `json:"bio"`
+ TwitterUsername *string `json:"twitter_username"`
+ PublicRepos int `json:"public_repos"`
+ PublicGists int `json:"public_gists"`
+ Followers int `json:"followers"`
+ Following int `json:"following"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ PrivateGists int `json:"private_gists"`
+ TotalPrivateRepos int `json:"total_private_repos"`
+ OwnedPrivateRepos int `json:"owned_private_repos"`
+ DiskUsage int `json:"disk_usage"`
+ Collaborators int `json:"collaborators"`
+ TwoFactorAuthentication bool `json:"two_factor_authentication"`
+ Plan *Plan `json:"plan"`
+ SuspendedAt *time.Time `json:"suspended_at"`
+ BusinessPlus bool `json:"business_plus"`
+ LdapDn *string `json:"ldap_dn"`
+}
+
+// Label contains label information
+type Label struct {
+ ID int `json:"id"`
+ NodeID string `json:"node_id"`
+ URL string `json:"url"`
+ Name string `json:"name"`
+ Description *string `json:"description"`
+ Color string `json:"color"`
+ Default bool `json:"default"`
+}
+
+// Milestone contains milestone information
+type Milestone struct {
+ URL string `json:"url"`
+ HTMLURL string `json:"html_url"`
+ LabelsURL string `json:"labels_url"`
+ ID int `json:"id"`
+ NodeID string `json:"node_id"`
+ Number int `json:"number"`
+ State string `json:"state"`
+ Title string `json:"title"`
+ Description *string `json:"description"`
+ Creator *User `json:"creator"`
+ OpenIssues int `json:"open_issues"`
+ ClosedIssues int `json:"closed_issues"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DueOn *time.Time `json:"due_on"`
+ ClosedAt *time.Time `json:"closed_at"`
+}
+
+// License contains license information
+type License struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ SpdxID string `json:"spdx_id"`
+ NodeID string `json:"node_id"`
+ HTMLURL string `json:"html_url"`
+}
+
+// SecurityAndAnalysis contains security and analysis information
+type SecurityAndAnalysis struct {
+ AdvancedSecurity *SecurityFeature `json:"advanced_security"`
+ SecretScanning *SecurityFeature `json:"secret_scanning"`
+ SecretScanningPushProtection *SecurityFeature `json:"secret_scanning_push_protection"`
+ DependabotSecurityUpdates *SecurityFeature `json:"dependabot_security_updates"`
+ DependencyGraph *SecurityFeature `json:"dependency_graph"`
+}
+
+// SecurityFeature contains security feature information
+type SecurityFeature struct {
+ Status string `json:"status"`
+}
+
+// Plan contains plan information
+type Plan struct {
+ Name string `json:"name"`
+ Space int `json:"space"`
+ Collaborators int `json:"collaborators"`
+ PrivateRepos int `json:"private_repos"`
+}
+
+// Changes contains information about what changed in the webhook
+type Changes struct {
+ Title *Change `json:"title,omitempty"`
+ Body *Change `json:"body,omitempty"`
+ Base *Change `json:"base,omitempty"`
+}
+
+// Change contains information about a specific change
+type Change struct {
+ From string `json:"from"`
+}
+
+// ValidateSignature validates GitHub webhook signature using SHA-256 HMAC
+func ValidateSignature(payload []byte, signature, secret string) error {
+ if secret == "" {
+ return errors.New("webhook secret not configured")
+ }
+
+ if !strings.HasPrefix(signature, "sha256=") {
+ return errors.New("signature must use sha256")
+ }
+
+ expectedMAC, err := hex.DecodeString(signature[7:])
+ if err != nil {
+ return fmt.Errorf("invalid signature format: %w", err)
+ }
+
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write(payload)
+ computedMAC := mac.Sum(nil)
+
+ if !hmac.Equal(expectedMAC, computedMAC) {
+ return errors.New("signature verification failed")
+ }
+
+ return nil
+}
+
+// ParseWebhook parses GitHub webhook payload
+func ParseWebhook(payload []byte) (*GitHubWebhook, error) {
+ var webhook GitHubWebhook
+ if err := json.Unmarshal(payload, &webhook); err != nil {
+ return nil, fmt.Errorf("failed to parse webhook: %w", err)
+ }
+ return &webhook, nil
+}
+
+// IsValidMergeEvent checks if webhook represents a merged PR
+func IsValidMergeEvent(webhook *GitHubWebhook) bool {
+ return webhook.Action == "closed" &&
+ webhook.PullRequest != nil &&
+ webhook.PullRequest.Merged &&
+ webhook.PullRequest.Head != nil
+}
+
+// ExtractTaskID extracts task ID from branch name like "solution/{task-id}" or "subtasks/{task-id}"
+func ExtractTaskID(branchName string) (string, error) {
+ // Match patterns like "solution/task-123", "subtasks/task-456", etc.
+ re := regexp.MustCompile(`^(?:solution|subtasks)/(.+)$`)
+ matches := re.FindStringSubmatch(branchName)
+
+ if len(matches) != 2 {
+ return "", fmt.Errorf("branch name '%s' does not match expected pattern (solution/{task-id} or subtasks/{task-id})", branchName)
+ }
+
+ return matches[1], nil
+}
+
+// ProcessMergeWebhook processes a GitHub PR merge webhook and returns the task ID
+// This function handles the complete GitHub webhook payload structure
+func ProcessMergeWebhook(payload []byte, signature, secret string) (string, error) {
+ // Validate signature
+ if err := ValidateSignature(payload, signature, secret); err != nil {
+ return "", fmt.Errorf("signature validation failed: %w", err)
+ }
+
+ // Parse webhook
+ webhook, err := ParseWebhook(payload)
+ if err != nil {
+ return "", fmt.Errorf("webhook parsing failed: %w", err)
+ }
+
+ // Check if it's a merge event
+ if !IsValidMergeEvent(webhook) {
+ return "", errors.New("not a valid merge event")
+ }
+
+ // Extract task ID from branch name
+ taskID, err := ExtractTaskID(webhook.PullRequest.Head.Ref)
+ if err != nil {
+ return "", fmt.Errorf("task ID extraction failed: %w", err)
+ }
+
+ return taskID, nil
+}
+
+// GetWebhookInfo returns comprehensive information about the webhook for debugging/logging
+func GetWebhookInfo(webhook *GitHubWebhook) map[string]interface{} {
+ info := map[string]interface{}{
+ "action": webhook.Action,
+ "number": webhook.Number,
+ }
+
+ if webhook.PullRequest != nil {
+ info["pr_id"] = webhook.PullRequest.ID
+ info["pr_title"] = webhook.PullRequest.Title
+ info["pr_state"] = webhook.PullRequest.State
+ info["pr_merged"] = webhook.PullRequest.Merged
+ info["pr_merged_at"] = webhook.PullRequest.MergedAt
+ info["pr_merged_by"] = webhook.PullRequest.MergedBy
+
+ if webhook.PullRequest.Head != nil {
+ info["head_ref"] = webhook.PullRequest.Head.Ref
+ info["head_sha"] = webhook.PullRequest.Head.SHA
+ }
+
+ if webhook.PullRequest.Base != nil {
+ info["base_ref"] = webhook.PullRequest.Base.Ref
+ info["base_sha"] = webhook.PullRequest.Base.SHA
+ }
+ }
+
+ if webhook.Repository != nil {
+ info["repo_name"] = webhook.Repository.Name
+ info["repo_full_name"] = webhook.Repository.FullName
+ }
+
+ if webhook.Sender != nil {
+ info["sender_login"] = webhook.Sender.Login
+ info["sender_id"] = webhook.Sender.ID
+ }
+
+ return info
+}
diff --git a/server/git/webhook_test.go b/server/git/webhook_test.go
new file mode 100644
index 0000000..d19b27f
--- /dev/null
+++ b/server/git/webhook_test.go
@@ -0,0 +1,189 @@
+package git
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+func TestProcessMergeWebhook(t *testing.T) {
+ // Create a sample GitHub webhook payload for a merged PR
+ webhookPayload := GitHubWebhook{
+ Action: "closed",
+ Number: 123,
+ PullRequest: &GitHubPullRequest{
+ ID: 456,
+ Number: 123,
+ State: "closed",
+ Title: "Add solution for task-123",
+ Merged: true,
+ MergedAt: func() *time.Time {
+ t := time.Now()
+ return &t
+ }(),
+ MergedBy: &User{
+ Login: "testuser",
+ ID: 789,
+ },
+ Head: &PullRequestBranch{
+ Ref: "solution/task-123",
+ SHA: "abc123def456",
+ },
+ Base: &PullRequestBranch{
+ Ref: "main",
+ SHA: "def456ghi789",
+ },
+ },
+ Repository: &Repository{
+ Name: "test-repo",
+ FullName: "testuser/test-repo",
+ },
+ Sender: &User{
+ Login: "testuser",
+ ID: 789,
+ },
+ }
+
+ // Convert to JSON
+ payload, err := json.Marshal(webhookPayload)
+ if err != nil {
+ t.Fatalf("Failed to marshal webhook payload: %v", err)
+ }
+
+ // Test signature validation (this will fail without proper secret)
+ _, err = ProcessMergeWebhook(payload, "sha256=invalid", "test-secret")
+ if err == nil {
+ t.Error("Expected signature validation to fail with invalid signature")
+ }
+
+ // Test with valid signature (you would need to generate this properly in a real test)
+ // For now, we'll test the parsing and validation logic separately
+ webhook, err := ParseWebhook(payload)
+ if err != nil {
+ t.Fatalf("Failed to parse webhook: %v", err)
+ }
+
+ if !IsValidMergeEvent(webhook) {
+ t.Error("Expected webhook to be a valid merge event")
+ }
+
+ taskID, err := ExtractTaskID(webhook.PullRequest.Head.Ref)
+ if err != nil {
+ t.Fatalf("Failed to extract task ID: %v", err)
+ }
+
+ if taskID != "task-123" {
+ t.Errorf("Expected task ID 'task-123', got '%s'", taskID)
+ }
+
+ // Test GetWebhookInfo function
+ info := GetWebhookInfo(webhook)
+ if info["action"] != "closed" {
+ t.Errorf("Expected action 'closed', got '%v'", info["action"])
+ }
+ if info["pr_title"] != "Add solution for task-123" {
+ t.Errorf("Expected title 'Add solution for task-123', got '%v'", info["pr_title"])
+ }
+ if info["head_ref"] != "solution/task-123" {
+ t.Errorf("Expected head_ref 'solution/task-123', got '%v'", info["head_ref"])
+ }
+}
+
+func TestExtractTaskID(t *testing.T) {
+ tests := []struct {
+ branchName string
+ expected string
+ shouldErr bool
+ }{
+ {"solution/task-123", "task-123", false},
+ {"subtasks/task-456", "task-456", false},
+ {"feature/new-feature", "", true},
+ {"main", "", true},
+ {"solution/", "", true},
+ {"subtasks/", "", true},
+ }
+
+ for _, test := range tests {
+ taskID, err := ExtractTaskID(test.branchName)
+ if test.shouldErr {
+ if err == nil {
+ t.Errorf("Expected error for branch '%s', but got none", test.branchName)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error for branch '%s': %v", test.branchName, err)
+ }
+ if taskID != test.expected {
+ t.Errorf("Expected task ID '%s' for branch '%s', got '%s'", test.expected, test.branchName, taskID)
+ }
+ }
+ }
+}
+
+func TestIsValidMergeEvent(t *testing.T) {
+ tests := []struct {
+ name string
+ webhook *GitHubWebhook
+ expected bool
+ }{
+ {
+ name: "valid merge event",
+ webhook: &GitHubWebhook{
+ Action: "closed",
+ PullRequest: &GitHubPullRequest{
+ Merged: true,
+ Head: &PullRequestBranch{Ref: "solution/task-123"},
+ },
+ },
+ expected: true,
+ },
+ {
+ name: "not closed",
+ webhook: &GitHubWebhook{
+ Action: "opened",
+ PullRequest: &GitHubPullRequest{
+ Merged: true,
+ Head: &PullRequestBranch{Ref: "solution/task-123"},
+ },
+ },
+ expected: false,
+ },
+ {
+ name: "not merged",
+ webhook: &GitHubWebhook{
+ Action: "closed",
+ PullRequest: &GitHubPullRequest{
+ Merged: false,
+ Head: &PullRequestBranch{Ref: "solution/task-123"},
+ },
+ },
+ expected: false,
+ },
+ {
+ name: "no pull request",
+ webhook: &GitHubWebhook{
+ Action: "closed",
+ },
+ expected: false,
+ },
+ {
+ name: "no head branch",
+ webhook: &GitHubWebhook{
+ Action: "closed",
+ PullRequest: &GitHubPullRequest{
+ Merged: true,
+ },
+ },
+ expected: false,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ result := IsValidMergeEvent(test.webhook)
+ if result != test.expected {
+ t.Errorf("Expected %v, got %v", test.expected, result)
+ }
+ })
+ }
+}