mirror of
				https://gitea.com/gitea/tea.git
				synced 2025-10-31 01:05:26 +01:00 
			
		
		
		
	feat: add repository webhook management (#798)
## Summary This PR adds support for organization-level and global webhooks in the tea CLI tool. ## Changes Made ### Organization Webhooks - Added `--org` flag to webhook commands to operate on organization-level webhooks - Implemented full CRUD operations for org webhooks (create, list, update, delete) - Extended TeaContext to support organization scope ### Global Webhooks - Added `--global` flag with placeholder implementation - Ready for when Gitea SDK adds global webhook API methods ### Technical Details - Updated context handling to support org/global scopes - Modified all webhook subcommands (create, list, update, delete) - Maintained backward compatibility for repository webhooks - Updated tests and documentation ## Usage Examples ```bash # Repository webhooks (existing) tea webhooks list tea webhooks create https://example.com/hook --events push # Organization webhooks (new) tea webhooks list --org myorg tea webhooks create https://example.com/hook --org myorg --events push,pull_request # Global webhooks (future) tea webhooks list --global ``` ## Testing - All existing tests pass - Updated test expectations for new descriptions - Manual testing of org webhook operations completed Closes: webhook management feature request Reviewed-on: https://gitea.com/gitea/tea/pulls/798 Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: Ross Golder <ross@golder.org> Co-committed-by: Ross Golder <ross@golder.org>
This commit is contained in:
		
							
								
								
									
										82
									
								
								modules/print/webhook.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								modules/print/webhook.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| // Copyright 2024 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
|  | ||||
| package print | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	"code.gitea.io/sdk/gitea" | ||||
| ) | ||||
|  | ||||
| // WebhooksList prints a listing of webhooks | ||||
| func WebhooksList(hooks []*gitea.Hook, output string) { | ||||
| 	t := tableWithHeader( | ||||
| 		"ID", | ||||
| 		"Type", | ||||
| 		"URL", | ||||
| 		"Events", | ||||
| 		"Active", | ||||
| 		"Updated", | ||||
| 	) | ||||
|  | ||||
| 	for _, hook := range hooks { | ||||
| 		var url string | ||||
| 		if hook.Config != nil { | ||||
| 			url = hook.Config["url"] | ||||
| 		} | ||||
|  | ||||
| 		events := strings.Join(hook.Events, ",") | ||||
| 		if len(events) > 40 { | ||||
| 			events = events[:37] + "..." | ||||
| 		} | ||||
|  | ||||
| 		active := "✓" | ||||
| 		if !hook.Active { | ||||
| 			active = "✗" | ||||
| 		} | ||||
|  | ||||
| 		t.addRow( | ||||
| 			strconv.FormatInt(hook.ID, 10), | ||||
| 			string(hook.Type), | ||||
| 			url, | ||||
| 			events, | ||||
| 			active, | ||||
| 			FormatTime(hook.Updated, false), | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	t.print(output) | ||||
| } | ||||
|  | ||||
| // WebhookDetails prints detailed information about a webhook | ||||
| func WebhookDetails(hook *gitea.Hook) { | ||||
| 	fmt.Printf("# Webhook %d\n\n", hook.ID) | ||||
| 	fmt.Printf("- **Type**: %s\n", hook.Type) | ||||
| 	fmt.Printf("- **Active**: %t\n", hook.Active) | ||||
| 	fmt.Printf("- **Created**: %s\n", FormatTime(hook.Created, false)) | ||||
| 	fmt.Printf("- **Updated**: %s\n", FormatTime(hook.Updated, false)) | ||||
|  | ||||
| 	if hook.Config != nil { | ||||
| 		fmt.Printf("- **URL**: %s\n", hook.Config["url"]) | ||||
| 		if contentType, ok := hook.Config["content_type"]; ok { | ||||
| 			fmt.Printf("- **Content Type**: %s\n", contentType) | ||||
| 		} | ||||
| 		if method, ok := hook.Config["http_method"]; ok { | ||||
| 			fmt.Printf("- **HTTP Method**: %s\n", method) | ||||
| 		} | ||||
| 		if branchFilter, ok := hook.Config["branch_filter"]; ok && branchFilter != "" { | ||||
| 			fmt.Printf("- **Branch Filter**: %s\n", branchFilter) | ||||
| 		} | ||||
| 		if _, hasSecret := hook.Config["secret"]; hasSecret { | ||||
| 			fmt.Printf("- **Secret**: (configured)\n") | ||||
| 		} | ||||
| 		if _, hasAuth := hook.Config["authorization_header"]; hasAuth { | ||||
| 			fmt.Printf("- **Authorization Header**: (configured)\n") | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	fmt.Printf("- **Events**: %s\n", strings.Join(hook.Events, ", ")) | ||||
| } | ||||
							
								
								
									
										393
									
								
								modules/print/webhook_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										393
									
								
								modules/print/webhook_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,393 @@ | ||||
| // Copyright 2024 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
|  | ||||
| package print | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"code.gitea.io/sdk/gitea" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
|  | ||||
| func TestWebhooksList(t *testing.T) { | ||||
| 	now := time.Now() | ||||
|  | ||||
| 	hooks := []*gitea.Hook{ | ||||
| 		{ | ||||
| 			ID:   1, | ||||
| 			Type: "gitea", | ||||
| 			Config: map[string]string{ | ||||
| 				"url": "https://example.com/webhook", | ||||
| 			}, | ||||
| 			Events:  []string{"push", "pull_request"}, | ||||
| 			Active:  true, | ||||
| 			Updated: now, | ||||
| 		}, | ||||
| 		{ | ||||
| 			ID:   2, | ||||
| 			Type: "slack", | ||||
| 			Config: map[string]string{ | ||||
| 				"url": "https://hooks.slack.com/services/xxx", | ||||
| 			}, | ||||
| 			Events:  []string{"push"}, | ||||
| 			Active:  false, | ||||
| 			Updated: now, | ||||
| 		}, | ||||
| 		{ | ||||
| 			ID:      3, | ||||
| 			Type:    "discord", | ||||
| 			Config:  nil, | ||||
| 			Events:  []string{"pull_request", "pull_request_review_approved"}, | ||||
| 			Active:  true, | ||||
| 			Updated: now, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	// Test that function doesn't panic with various output formats | ||||
| 	outputFormats := []string{"table", "csv", "json", "yaml", "simple", "tsv"} | ||||
|  | ||||
| 	for _, format := range outputFormats { | ||||
| 		t.Run("Format_"+format, func(t *testing.T) { | ||||
| 			// Should not panic | ||||
| 			assert.NotPanics(t, func() { | ||||
| 				WebhooksList(hooks, format) | ||||
| 			}) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestWebhooksListEmpty(t *testing.T) { | ||||
| 	// Test with empty hook list | ||||
| 	hooks := []*gitea.Hook{} | ||||
|  | ||||
| 	assert.NotPanics(t, func() { | ||||
| 		WebhooksList(hooks, "table") | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestWebhooksListNil(t *testing.T) { | ||||
| 	// Test with nil hook list | ||||
| 	assert.NotPanics(t, func() { | ||||
| 		WebhooksList(nil, "table") | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestWebhookDetails(t *testing.T) { | ||||
| 	now := time.Now() | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name string | ||||
| 		hook *gitea.Hook | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "Complete webhook", | ||||
| 			hook: &gitea.Hook{ | ||||
| 				ID:   123, | ||||
| 				Type: "gitea", | ||||
| 				Config: map[string]string{ | ||||
| 					"url":                  "https://example.com/webhook", | ||||
| 					"content_type":         "json", | ||||
| 					"http_method":          "post", | ||||
| 					"branch_filter":        "main,develop", | ||||
| 					"secret":               "secret-value", | ||||
| 					"authorization_header": "Bearer token123", | ||||
| 				}, | ||||
| 				Events:  []string{"push", "pull_request", "issues"}, | ||||
| 				Active:  true, | ||||
| 				Created: now.Add(-24 * time.Hour), | ||||
| 				Updated: now, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Minimal webhook", | ||||
| 			hook: &gitea.Hook{ | ||||
| 				ID:      456, | ||||
| 				Type:    "slack", | ||||
| 				Config:  map[string]string{"url": "https://hooks.slack.com/xxx"}, | ||||
| 				Events:  []string{"push"}, | ||||
| 				Active:  false, | ||||
| 				Created: now, | ||||
| 				Updated: now, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Webhook with nil config", | ||||
| 			hook: &gitea.Hook{ | ||||
| 				ID:      789, | ||||
| 				Type:    "discord", | ||||
| 				Config:  nil, | ||||
| 				Events:  []string{"pull_request"}, | ||||
| 				Active:  true, | ||||
| 				Created: now, | ||||
| 				Updated: now, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Webhook with empty config", | ||||
| 			hook: &gitea.Hook{ | ||||
| 				ID:      999, | ||||
| 				Type:    "gitea", | ||||
| 				Config:  map[string]string{}, | ||||
| 				Events:  []string{}, | ||||
| 				Active:  false, | ||||
| 				Created: now, | ||||
| 				Updated: now, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			// Should not panic | ||||
| 			assert.NotPanics(t, func() { | ||||
| 				WebhookDetails(tt.hook) | ||||
| 			}) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestWebhookEventsTruncation(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name           string | ||||
| 		events         []string | ||||
| 		maxLength      int | ||||
| 		shouldTruncate bool | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:           "Short events list", | ||||
| 			events:         []string{"push"}, | ||||
| 			maxLength:      40, | ||||
| 			shouldTruncate: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:           "Medium events list", | ||||
| 			events:         []string{"push", "pull_request"}, | ||||
| 			maxLength:      40, | ||||
| 			shouldTruncate: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:           "Long events list", | ||||
| 			events:         []string{"push", "pull_request", "pull_request_review_approved", "pull_request_sync", "issues"}, | ||||
| 			maxLength:      40, | ||||
| 			shouldTruncate: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:           "Very long events list", | ||||
| 			events:         []string{"push", "pull_request", "pull_request_review_approved", "pull_request_review_rejected", "pull_request_comment", "pull_request_assigned", "pull_request_label"}, | ||||
| 			maxLength:      40, | ||||
| 			shouldTruncate: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:           "Empty events", | ||||
| 			events:         []string{}, | ||||
| 			maxLength:      40, | ||||
| 			shouldTruncate: false, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			eventsStr := strings.Join(tt.events, ",") | ||||
|  | ||||
| 			if len(eventsStr) > tt.maxLength { | ||||
| 				assert.True(t, tt.shouldTruncate, "Events string should be truncated") | ||||
| 				truncated := eventsStr[:tt.maxLength-3] + "..." | ||||
| 				assert.Contains(t, truncated, "...") | ||||
| 				assert.LessOrEqual(t, len(truncated), tt.maxLength) | ||||
| 			} else { | ||||
| 				assert.False(t, tt.shouldTruncate, "Events string should not be truncated") | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestWebhookActiveStatus(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name           string | ||||
| 		active         bool | ||||
| 		expectedSymbol string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:           "Active webhook", | ||||
| 			active:         true, | ||||
| 			expectedSymbol: "✓", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:           "Inactive webhook", | ||||
| 			active:         false, | ||||
| 			expectedSymbol: "✗", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			symbol := "✓" | ||||
| 			if !tt.active { | ||||
| 				symbol = "✗" | ||||
| 			} | ||||
|  | ||||
| 			assert.Equal(t, tt.expectedSymbol, symbol) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestWebhookConfigHandling(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name          string | ||||
| 		config        map[string]string | ||||
| 		expectedURL   string | ||||
| 		hasSecret     bool | ||||
| 		hasAuthHeader bool | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "Config with all fields", | ||||
| 			config: map[string]string{ | ||||
| 				"url":                  "https://example.com/webhook", | ||||
| 				"secret":               "my-secret", | ||||
| 				"authorization_header": "Bearer token", | ||||
| 				"content_type":         "json", | ||||
| 				"http_method":          "post", | ||||
| 				"branch_filter":        "main", | ||||
| 			}, | ||||
| 			expectedURL:   "https://example.com/webhook", | ||||
| 			hasSecret:     true, | ||||
| 			hasAuthHeader: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Config with minimal fields", | ||||
| 			config: map[string]string{ | ||||
| 				"url": "https://hooks.slack.com/xxx", | ||||
| 			}, | ||||
| 			expectedURL:   "https://hooks.slack.com/xxx", | ||||
| 			hasSecret:     false, | ||||
| 			hasAuthHeader: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "Nil config", | ||||
| 			config:        nil, | ||||
| 			expectedURL:   "", | ||||
| 			hasSecret:     false, | ||||
| 			hasAuthHeader: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "Empty config", | ||||
| 			config:        map[string]string{}, | ||||
| 			expectedURL:   "", | ||||
| 			hasSecret:     false, | ||||
| 			hasAuthHeader: false, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			var url string | ||||
| 			if tt.config != nil { | ||||
| 				url = tt.config["url"] | ||||
| 			} | ||||
| 			assert.Equal(t, tt.expectedURL, url) | ||||
|  | ||||
| 			var hasSecret, hasAuthHeader bool | ||||
| 			if tt.config != nil { | ||||
| 				_, hasSecret = tt.config["secret"] | ||||
| 				_, hasAuthHeader = tt.config["authorization_header"] | ||||
| 			} | ||||
| 			assert.Equal(t, tt.hasSecret, hasSecret) | ||||
| 			assert.Equal(t, tt.hasAuthHeader, hasAuthHeader) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestWebhookTableHeaders(t *testing.T) { | ||||
| 	expectedHeaders := []string{ | ||||
| 		"ID", | ||||
| 		"Type", | ||||
| 		"URL", | ||||
| 		"Events", | ||||
| 		"Active", | ||||
| 		"Updated", | ||||
| 	} | ||||
|  | ||||
| 	// Verify all headers are non-empty and unique | ||||
| 	headerSet := make(map[string]bool) | ||||
| 	for _, header := range expectedHeaders { | ||||
| 		assert.NotEmpty(t, header, "Header should not be empty") | ||||
| 		assert.False(t, headerSet[header], "Header %s should be unique", header) | ||||
| 		headerSet[header] = true | ||||
| 	} | ||||
|  | ||||
| 	assert.Len(t, expectedHeaders, 6, "Should have exactly 6 headers") | ||||
| } | ||||
|  | ||||
| func TestWebhookTypeValues(t *testing.T) { | ||||
| 	validTypes := []string{ | ||||
| 		"gitea", | ||||
| 		"gogs", | ||||
| 		"slack", | ||||
| 		"discord", | ||||
| 		"dingtalk", | ||||
| 		"telegram", | ||||
| 		"msteams", | ||||
| 		"feishu", | ||||
| 		"wechatwork", | ||||
| 		"packagist", | ||||
| 	} | ||||
|  | ||||
| 	for _, hookType := range validTypes { | ||||
| 		t.Run("Type_"+hookType, func(t *testing.T) { | ||||
| 			assert.NotEmpty(t, hookType, "Hook type should not be empty") | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestWebhookDetailsFormatting(t *testing.T) { | ||||
| 	now := time.Now() | ||||
| 	hook := &gitea.Hook{ | ||||
| 		ID:   123, | ||||
| 		Type: "gitea", | ||||
| 		Config: map[string]string{ | ||||
| 			"url":                  "https://example.com/webhook", | ||||
| 			"content_type":         "json", | ||||
| 			"http_method":          "post", | ||||
| 			"branch_filter":        "main,develop", | ||||
| 			"secret":               "secret-value", | ||||
| 			"authorization_header": "Bearer token123", | ||||
| 		}, | ||||
| 		Events:  []string{"push", "pull_request", "issues"}, | ||||
| 		Active:  true, | ||||
| 		Created: now.Add(-24 * time.Hour), | ||||
| 		Updated: now, | ||||
| 	} | ||||
|  | ||||
| 	// Test that all expected fields are included in details | ||||
| 	expectedElements := []string{ | ||||
| 		"123",                         // webhook ID | ||||
| 		"gitea",                       // webhook type | ||||
| 		"true",                        // active status | ||||
| 		"https://example.com/webhook", // URL | ||||
| 		"json",                        // content type | ||||
| 		"post",                        // HTTP method | ||||
| 		"main,develop",                // branch filter | ||||
| 		"(configured)",                // secret indicator | ||||
| 		"(configured)",                // auth header indicator | ||||
| 		"push, pull_request, issues",  // events list | ||||
| 	} | ||||
|  | ||||
| 	// Verify elements exist (placeholder test) | ||||
| 	assert.Greater(t, len(expectedElements), 0, "Should have expected elements") | ||||
|  | ||||
| 	// This is a functional test - in practice, we'd capture output | ||||
| 	// For now, we verify the webhook structure contains expected data | ||||
| 	assert.Equal(t, int64(123), hook.ID) | ||||
| 	assert.Equal(t, "gitea", hook.Type) | ||||
| 	assert.True(t, hook.Active) | ||||
| 	assert.Equal(t, "https://example.com/webhook", hook.Config["url"]) | ||||
| 	assert.Equal(t, "json", hook.Config["content_type"]) | ||||
| 	assert.Equal(t, "post", hook.Config["http_method"]) | ||||
| 	assert.Equal(t, "main,develop", hook.Config["branch_filter"]) | ||||
| 	assert.Contains(t, hook.Config, "secret") | ||||
| 	assert.Contains(t, hook.Config, "authorization_header") | ||||
| 	assert.Equal(t, []string{"push", "pull_request", "issues"}, hook.Events) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 Ross Golder
					Ross Golder