Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 294 additions & 0 deletions scripts/apply-branch-protection.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
#!/bin/bash
# Apply Branch Protection Rules
# Configures GitHub branch protection for the hybrid workflow

set -euo pipefail

# Color codes for output
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# Configuration
GITHUB_API="https://api.github.com"
REQUIRED_CHECKS=(
"build"
"test"
"claude-standards"
)

# Function to display usage
usage() {
echo "Usage: $0 [options]"
echo ""
echo "Applies branch protection rules for the hybrid workflow."
echo ""
echo "Options:"
echo " --token TOKEN GitHub personal access token (or set GITHUB_TOKEN env var)"
echo " --owner OWNER Repository owner (default: detected from git remote)"
echo " --repo REPO Repository name (default: detected from git remote)"
echo " --dry-run Show what would be done without making changes"
echo " --help Show this help message"
echo ""
echo "Requirements:"
echo " - GitHub personal access token with repo permissions"
echo " - gh CLI tool installed (or provide --token)"
echo ""
}

# Function to get repository info from git remote
get_repo_info() {
local remote_url=$(git remote get-url origin 2>/dev/null || echo "")

if [[ -z "$remote_url" ]]; then
echo -e "${RED}❌ Error: No git remote found${NC}"
exit 1
fi

# Extract owner and repo from URL
# Works with both HTTPS and SSH URLs
if [[ "$remote_url" =~ github\.com[:/]([^/]+)/([^/.]+)(\.git)?$ ]]; then
OWNER="${BASH_REMATCH[1]}"
REPO="${BASH_REMATCH[2]}"
else
echo -e "${RED}❌ Error: Could not parse GitHub repository from remote URL${NC}"
exit 1
fi
}

# Function to get GitHub token
get_github_token() {
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
TOKEN="$GITHUB_TOKEN"
elif command -v gh &> /dev/null && gh auth status &>/dev/null; then
TOKEN=$(gh auth token)
else
echo -e "${RED}❌ Error: No GitHub token found${NC}"
echo "Please provide --token or set GITHUB_TOKEN environment variable"
exit 1
fi
}

# Function to apply protection to a branch
apply_branch_protection() {
local branch=$1
local settings=$2

echo -e "${BLUE}🔒 Configuring protection for $branch branch...${NC}"

if [[ "$DRY_RUN" == "true" ]]; then
echo -e "${YELLOW}[DRY RUN] Would apply these settings:${NC}"
echo "$settings" | jq '.'
return
fi

# Apply protection rules
local response=$(curl -s -X PUT \
-H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d "$settings" \
"$GITHUB_API/repos/$OWNER/$REPO/branches/$branch/protection")

# Check if successful
if [[ $(echo "$response" | jq -r '.url // empty') ]]; then
Comment on lines +88 to +96
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition checks if the URL field exists, but GitHub API errors also return JSON with different fields. This could incorrectly report success when the API returns an error. Should check for specific success indicators or HTTP status codes.

Suggested change
local response=$(curl -s -X PUT \
-H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d "$settings" \
"$GITHUB_API/repos/$OWNER/$REPO/branches/$branch/protection")
# Check if successful
if [[ $(echo "$response" | jq -r '.url // empty') ]]; then
local response
local http_status
response=$(curl -s -w "%{http_code}" -o /tmp/response_body -X PUT \
-H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d "$settings" \
"$GITHUB_API/repos/$OWNER/$REPO/branches/$branch/protection")
http_status=$(tail -n1 <<< "$response")
response=$(cat /tmp/response_body)
# Check if successful
if [[ "$http_status" -eq 200 || "$http_status" -eq 204 ]]; then

Copilot uses AI. Check for mistakes.
echo -e "${GREEN}✅ Successfully protected $branch branch${NC}"
else
echo -e "${RED}❌ Failed to protect $branch branch${NC}"
echo "$response" | jq '.'
return 1
fi
}

# Function to create main branch protection settings
create_main_protection() {
cat <<EOF
{
"required_status_checks": {
"strict": true,
"contexts": $(printf '%s\n' "${REQUIRED_CHECKS[@]}" | jq -R . | jq -s .)
},
"enforce_admins": false,
"required_pull_request_reviews": {
"dismissal_restrictions": {},
"dismiss_stale_reviews": true,
"require_code_owner_reviews": false,
"required_approving_review_count": 1,
"require_last_push_approval": false
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false,
"block_creations": false,
"required_conversation_resolution": true,
"lock_branch": false,
"allow_fork_syncing": true
}
EOF
}

# Function to create dev branch protection settings
create_dev_protection() {
cat <<EOF
{
"required_status_checks": {
"strict": false,
"contexts": $(printf '%s\n' "${REQUIRED_CHECKS[@]}" | jq -R . | jq -s .)
},
"enforce_admins": false,
"required_pull_request_reviews": null,
"restrictions": null,
"allow_force_pushes": true,
"allow_deletions": false,
"block_creations": false,
"required_conversation_resolution": false,
"lock_branch": false,
"allow_fork_syncing": true
}
EOF
}

# Function to check current protection status
check_protection_status() {
local branch=$1

echo -e "${BLUE}🔍 Checking current protection for $branch...${NC}"

local response=$(curl -s \
-H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"$GITHUB_API/repos/$OWNER/$REPO/branches/$branch/protection")

if [[ $(echo "$response" | jq -r '.message // empty') == "Branch not protected" ]]; then
echo -e "${YELLOW}⚠️ Branch $branch is not protected${NC}"
return 1
else
echo -e "${GREEN}✓ Branch $branch is already protected${NC}"
if [[ "$VERBOSE" == "true" ]]; then
echo "$response" | jq '.'
fi
return 0
fi
}

# Function to show summary
show_summary() {
echo -e "\n${GREEN}📊 Branch Protection Summary${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "Repository: ${YELLOW}$OWNER/$REPO${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${GREEN}Main Branch Protection:${NC}"
echo " ✓ Requires PR reviews (1 approval)"
echo " ✓ Dismisses stale reviews"
echo " ✓ Requires status checks to pass"
echo " ✓ Requires branches to be up to date"
echo " ✓ Requires conversation resolution"
echo " ✗ No force pushes allowed"
echo ""
echo -e "${GREEN}Dev Branch Protection:${NC}"
echo " ✗ No PR reviews required"
echo " ✓ Requires status checks to pass"
echo " ✗ Branches don't need to be up to date"
echo " ✗ No conversation resolution required"
echo " ✓ Force pushes allowed (for rebasing)"
echo ""
}

# Main script
main() {
# Parse arguments
DRY_RUN=false
VERBOSE=false
TOKEN=""
OWNER=""
REPO=""

while [[ $# -gt 0 ]]; do
case $1 in
--token)
TOKEN="$2"
shift 2
;;
--owner)
OWNER="$2"
shift 2
;;
--repo)
REPO="$2"
shift 2
;;
--dry-run)
DRY_RUN=true
shift
;;
--verbose)
VERBOSE=true
shift
;;
--help)
usage
exit 0
;;
*)
echo -e "${RED}❌ Unknown option: $1${NC}"
usage
exit 1
;;
esac
done

# Get repository info if not provided
if [[ -z "$OWNER" ]] || [[ -z "$REPO" ]]; then
get_repo_info
fi

echo -e "${BLUE}🚀 Applying branch protection rules${NC}"
echo -e "Repository: ${YELLOW}$OWNER/$REPO${NC}"

# Get GitHub token if not provided
if [[ -z "$TOKEN" ]]; then
get_github_token
fi

# Check if dry run
if [[ "$DRY_RUN" == "true" ]]; then
echo -e "${YELLOW}🔍 DRY RUN MODE - No changes will be made${NC}"
fi

# Apply protection to main branch
echo ""
if ! check_protection_status "main" || [[ "$DRY_RUN" == "true" ]]; then
apply_branch_protection "main" "$(create_main_protection)"
fi

# Apply protection to dev branch
echo ""
if ! check_protection_status "dev" || [[ "$DRY_RUN" == "true" ]]; then
apply_branch_protection "dev" "$(create_dev_protection)"
fi

# Show summary
if [[ "$DRY_RUN" != "true" ]]; then
show_summary
fi

echo -e "\n${GREEN}✅ Branch protection configuration complete!${NC}"
}

# Check for required tools
if ! command -v jq &> /dev/null; then
echo -e "${RED}❌ Error: jq is required but not installed${NC}"
echo "Install with: brew install jq"
exit 1
fi

if ! command -v curl &> /dev/null; then
echo -e "${RED}❌ Error: curl is required but not installed${NC}"
exit 1
fi

# Run main function
main "$@"
Comment on lines +281 to +294
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependency checks should be moved to the beginning of the main() function or before it's called. Currently, if users run with --help, they'll see the error about missing jq even though they just want to see usage information.

Suggested change
# Check for required tools
if ! command -v jq &> /dev/null; then
echo -e "${RED}❌ Error: jq is required but not installed${NC}"
echo "Install with: brew install jq"
exit 1
fi
if ! command -v curl &> /dev/null; then
echo -e "${RED}❌ Error: curl is required but not installed${NC}"
exit 1
fi
# Run main function
main "$@"
# Run main function
main "$@"
# Check for required tools (moved into main function)

Copilot uses AI. Check for mistakes.
Loading
Loading