Skip to content

Commit ef58425

Browse files
authored
Merge pull request #8 from mona-actions:amenocal/branch-protection
Adds branch protection and webhook count
2 parents 7c9aeb0 + 58a62e6 commit ef58425

6 files changed

Lines changed: 437 additions & 57 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ gh migration-validator export \
132132
"tags": 8,
133133
"releases": 3,
134134
"commits": 150,
135-
"latest_commit_sha": "abc123def456"
135+
"latest_commit_sha": "abc123def456",
136+
"branch_protection_rules": 4,
137+
"webhooks": 2
136138
}
137139
}
138140
```
@@ -217,6 +219,8 @@ The tool compares the following metrics between source and target repositories:
217219
- **Tags**: Total count of Git tags
218220
- **Releases**: Total count of GitHub releases
219221
- **Commits**: Total commit count on default branch
222+
- **Branch Protection Rules**: Total count of branch protection rules configured for the repository
223+
- **Webhooks**: Total count of active repository webhooks
220224
- **Latest Commit SHA**: Ensures both repositories have the same latest commit in default branch
221225

222226
## Validation Results

internal/api/api.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,3 +481,87 @@ func (api *GitHubAPI) GetLatestCommitHash(clientType ClientType, owner, name str
481481

482482
return query.Repository.DefaultBranchRef.Target.Commit.OID, nil
483483
}
484+
485+
// GetBranchProtectionRulesCount retrieves the total count of branch protection rules for a repository using GraphQL
486+
func (api *GitHubAPI) GetBranchProtectionRulesCount(clientType ClientType, owner, name string) (int, error) {
487+
ctx := context.Background()
488+
489+
var query struct {
490+
Repository struct {
491+
NameWithOwner string
492+
BranchProtectionRules struct {
493+
TotalCount int
494+
}
495+
} `graphql:"repository(owner: $owner, name: $name)"`
496+
}
497+
498+
variables := map[string]interface{}{
499+
"owner": githubv4.String(owner),
500+
"name": githubv4.String(name),
501+
}
502+
503+
var client *RateLimitAwareGraphQLClient
504+
var clientName string
505+
506+
switch clientType {
507+
case SourceClient:
508+
client = api.sourceGraphClient
509+
clientName = "source"
510+
case TargetClient:
511+
client = api.targetGraphClient
512+
clientName = "target"
513+
default:
514+
return 0, fmt.Errorf("invalid client type")
515+
}
516+
517+
err := client.Query(ctx, &query, variables)
518+
if err != nil {
519+
return 0, fmt.Errorf("failed to query %s repository branch protection rules count: %v", clientName, err)
520+
}
521+
522+
return query.Repository.BranchProtectionRules.TotalCount, nil
523+
}
524+
525+
// GetWebhookCount retrieves the count of active webhooks for a repository using REST API
526+
func (api *GitHubAPI) GetWebhookCount(clientType ClientType, owner, name string) (int, error) {
527+
ctx := context.Background()
528+
529+
var client *github.Client
530+
var clientName string
531+
532+
switch clientType {
533+
case SourceClient:
534+
client = api.sourceClient
535+
clientName = "source"
536+
case TargetClient:
537+
client = api.targetClient
538+
clientName = "target"
539+
default:
540+
return 0, fmt.Errorf("invalid client type")
541+
}
542+
543+
// List all webhooks for the repository
544+
opts := &github.ListOptions{PerPage: 100}
545+
var activeWebhookCount int
546+
547+
for {
548+
webhooks, resp, err := client.Repositories.ListHooks(ctx, owner, name, opts)
549+
if err != nil {
550+
return 0, fmt.Errorf("failed to query %s repository webhook count: %v", clientName, err)
551+
}
552+
553+
// Count only active webhooks
554+
for _, webhook := range webhooks {
555+
if webhook.Active != nil && *webhook.Active {
556+
activeWebhookCount++
557+
}
558+
}
559+
560+
if resp.NextPage == 0 {
561+
break
562+
}
563+
opts.Page = resp.NextPage
564+
}
565+
566+
return activeWebhookCount, nil
567+
}

internal/api/api_test.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,3 +687,213 @@ func TestPRCounts_TotalCalculation(t *testing.T) {
687687
})
688688
}
689689
}
690+
691+
func TestGetBranchProtectionRulesCount(t *testing.T) {
692+
viper.Set("SOURCE_TOKEN", "source-token")
693+
viper.Set("TARGET_TOKEN", "target-token")
694+
695+
tests := []struct {
696+
name string
697+
clientType ClientType
698+
owner string
699+
repo string
700+
wantError bool
701+
}{
702+
{
703+
name: "source client valid request",
704+
clientType: SourceClient,
705+
owner: "testowner",
706+
repo: "testrepo",
707+
wantError: true, // Will error in test due to no real connection
708+
},
709+
{
710+
name: "target client valid request",
711+
clientType: TargetClient,
712+
owner: "testowner",
713+
repo: "testrepo",
714+
wantError: true, // Will error in test due to no real connection
715+
},
716+
{
717+
name: "invalid client type",
718+
clientType: ClientType(999),
719+
owner: "testowner",
720+
repo: "testrepo",
721+
wantError: true,
722+
},
723+
}
724+
725+
for _, tt := range tests {
726+
t.Run(tt.name, func(t *testing.T) {
727+
resetAPI()
728+
api := GetAPI()
729+
730+
count, err := api.GetBranchProtectionRulesCount(tt.clientType, tt.owner, tt.repo)
731+
732+
if (err != nil) != tt.wantError {
733+
t.Errorf("GetBranchProtectionRulesCount() error = %v, wantError %v", err, tt.wantError)
734+
return
735+
}
736+
737+
if !tt.wantError && count < 0 {
738+
t.Errorf("GetBranchProtectionRulesCount() returned negative count: %d", count)
739+
}
740+
})
741+
}
742+
}
743+
744+
func TestGitHubAPI_GetWebhookCount(t *testing.T) {
745+
tests := []struct {
746+
name string
747+
clientType ClientType
748+
owner string
749+
repo string
750+
responseBody string
751+
expectedCount int
752+
expectedError bool
753+
}{
754+
{
755+
name: "successful webhook count - multiple webhooks",
756+
clientType: SourceClient,
757+
owner: "testowner",
758+
repo: "testrepo",
759+
responseBody: `[
760+
{
761+
"id": 1,
762+
"name": "web",
763+
"active": true,
764+
"events": ["push", "pull_request"],
765+
"config": {
766+
"url": "https://example.com/webhook1",
767+
"content_type": "json"
768+
}
769+
},
770+
{
771+
"id": 2,
772+
"name": "web",
773+
"active": true,
774+
"events": ["issues"],
775+
"config": {
776+
"url": "https://example.com/webhook2",
777+
"content_type": "json"
778+
}
779+
}
780+
]`,
781+
expectedCount: 2,
782+
expectedError: false,
783+
},
784+
{
785+
name: "no webhooks",
786+
clientType: TargetClient,
787+
owner: "testowner",
788+
repo: "testrepo",
789+
responseBody: `[]`,
790+
expectedCount: 0,
791+
expectedError: false,
792+
},
793+
{
794+
name: "single webhook",
795+
clientType: SourceClient,
796+
owner: "testowner",
797+
repo: "testrepo",
798+
responseBody: `[
799+
{
800+
"id": 1,
801+
"name": "web",
802+
"active": true,
803+
"events": ["push"],
804+
"config": {
805+
"url": "https://example.com/webhook",
806+
"content_type": "json"
807+
}
808+
}
809+
]`,
810+
expectedCount: 1,
811+
expectedError: false,
812+
},
813+
{
814+
name: "mixed active and inactive webhooks - only counts active",
815+
clientType: SourceClient,
816+
owner: "testowner",
817+
repo: "testrepo",
818+
responseBody: `[
819+
{
820+
"id": 1,
821+
"name": "web",
822+
"active": true,
823+
"events": ["push"],
824+
"config": {
825+
"url": "https://example.com/webhook1",
826+
"content_type": "json"
827+
}
828+
},
829+
{
830+
"id": 2,
831+
"name": "web",
832+
"active": false,
833+
"events": ["pull_request"],
834+
"config": {
835+
"url": "https://example.com/webhook2",
836+
"content_type": "json"
837+
}
838+
},
839+
{
840+
"id": 3,
841+
"name": "web",
842+
"active": true,
843+
"events": ["issues"],
844+
"config": {
845+
"url": "https://example.com/webhook3",
846+
"content_type": "json"
847+
}
848+
}
849+
]`,
850+
expectedCount: 2,
851+
expectedError: false,
852+
},
853+
}
854+
855+
for _, tt := range tests {
856+
t.Run(tt.name, func(t *testing.T) {
857+
// Create mock transport that returns the test response
858+
mockTransport := &mockRoundTripper{
859+
roundTripFunc: func(req *http.Request) (*http.Response, error) {
860+
// Verify this is a webhook list request
861+
if !strings.Contains(req.URL.Path, "/repos/"+tt.owner+"/"+tt.repo+"/hooks") {
862+
t.Errorf("Expected webhook API endpoint, got: %s", req.URL.Path)
863+
}
864+
865+
return &http.Response{
866+
StatusCode: 200,
867+
Body: io.NopCloser(strings.NewReader(tt.responseBody)),
868+
Header: make(http.Header),
869+
}, nil
870+
},
871+
}
872+
873+
api := createTestAPI(mockTransport)
874+
count, err := api.GetWebhookCount(tt.clientType, tt.owner, tt.repo)
875+
876+
if tt.expectedError {
877+
if err == nil {
878+
t.Errorf("Expected error, but got none")
879+
}
880+
} else {
881+
if err != nil {
882+
t.Errorf("Unexpected error: %v", err)
883+
}
884+
if count != tt.expectedCount {
885+
t.Errorf("Expected count %d, got %d", tt.expectedCount, count)
886+
}
887+
}
888+
})
889+
}
890+
}
891+
892+
// mockRoundTripper implements http.RoundTripper for testing
893+
type mockRoundTripper struct {
894+
roundTripFunc func(req *http.Request) (*http.Response, error)
895+
}
896+
897+
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
898+
return m.roundTripFunc(req)
899+
}

internal/export/export.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ func exportToCSV(data ExportData, filename string) error {
128128
"releases_count",
129129
"commits_count",
130130
"latest_commit_sha",
131+
"branch_protection_rules_count",
132+
"webhooks_count",
131133
}
132134
if err := writer.Write(header); err != nil {
133135
return fmt.Errorf("failed to write CSV header: %w", err)
@@ -155,6 +157,8 @@ func exportToCSV(data ExportData, filename string) error {
155157
fmt.Sprintf("%d", data.Repository.Releases),
156158
fmt.Sprintf("%d", data.Repository.CommitCount),
157159
data.Repository.LatestCommitSHA,
160+
fmt.Sprintf("%d", data.Repository.BranchProtectionRules),
161+
fmt.Sprintf("%d", data.Repository.Webhooks),
158162
}
159163
if err := writer.Write(record); err != nil {
160164
return fmt.Errorf("failed to write CSV record: %w", err)

0 commit comments

Comments
 (0)