diff --git a/BUDGET_IMPROVEMENTS.md b/BUDGET_IMPROVEMENTS.md new file mode 100644 index 0000000..709ec20 --- /dev/null +++ b/BUDGET_IMPROVEMENTS.md @@ -0,0 +1,108 @@ +# Budget System Improvements for Repository Cost Center Assignment + +## Overview +This document outlines the budget system improvements needed for the repository cost center assignment feature to support multiple products and be more resilient. + +## Current Implementation Issues +1. **Hardcoded Budget Amount**: `create_cost_center_budget()` uses hardcoded $0 amounts +2. **Missing Actions Support**: No support for Actions product budgets +3. **Limited Product Support**: Only supports Copilot PRU budgets +4. **Manual Budget Type Selection**: No abstraction for different budget types + +## Proposed Improvements + +### 1. **Configurable Budget Amounts** +```python +def create_cost_center_budget(self, cost_center_id: str, cost_center_name: str, budget_amount: int = 100) -> bool: + # Instead of hardcoded budget_amount: 0 +``` + +### 2. **Product-Agnostic Budget Creation** +```python +def create_product_budget(self, cost_center_id: str, cost_center_name: str, product: str, amount: int) -> bool: + """ + Create a product-level budget for a cost center. + + Args: + cost_center_id: UUID of the cost center + cost_center_name: Name of the cost center (for logging) + product: Product name (e.g., 'actions', 'copilot', 'packages') + amount: Budget amount in dollars + """ + # Dynamic budget type selection based on product + budget_type = self._get_budget_type_for_product(product) + product_sku = self._get_product_sku(product) +``` + +### 3. **Product Registry System** +```python +# Configuration-driven product support +SUPPORTED_PRODUCTS = { + 'actions': { + 'budget_type': 'ProductPricing', + 'sku': 'actions', + 'default_amount': 125 + }, + 'copilot': { + 'budget_type': 'SkuPricing', + 'sku': 'copilot_premium_request', + 'default_amount': 100 + }, + 'packages': { + 'budget_type': 'ProductPricing', + 'sku': 'packages', + 'default_amount': 50 + }, + 'codespaces': { + 'budget_type': 'ProductPricing', + 'sku': 'codespaces', + 'default_amount': 200 + } +} +``` + +### 4. **Budget Existence Checking** +```python +def check_cost_center_has_product_budget(self, cost_center_id: str, cost_center_name: str, product: str) -> bool: + """Check if a cost center already has a budget for a specific product.""" +``` + +### 5. **Configuration-Driven Budget Management** +```yaml +# config.yaml extension +budgets: + enabled: true + products: + - name: copilot + amount: 100 + enabled: true + - name: actions + amount: 125 + enabled: true + - name: packages + amount: 50 + enabled: false +``` + +## Implementation Priority + +### Phase 1 (Critical - Include in current PR) +- ✅ Fix configurable budget amounts +- ✅ Add Actions product budget support +- ✅ Add budget existence checking + +### Phase 2 (Future Enhancement) +- 🔄 Product registry system +- 🔄 Configuration-driven budget management +- 🔄 Support for additional products (Packages, Codespaces) + +### Phase 3 (Advanced Features) +- 🔄 Budget alerting and monitoring +- 🔄 Cost allocation reporting +- 🔄 Budget approval workflows + +## Benefits +1. **Extensibility**: Easy to add new products without code changes +2. **Configurability**: Customers can customize budget amounts per product +3. **Resilience**: Handles API changes better with abstracted product definitions +4. **Maintainability**: Centralized product configuration reduces duplication \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 751527c..a69a2c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Repository-Based Cost Center Assignment**: New mode for assigning repositories to cost centers based on custom properties + - Explicit mapping mode: Map custom property values to specific cost centers + - Works with any repository custom property (team, service, environment, etc.) + - Automatic cost center creation for new mappings + - Full pagination support for organizations with many repositories + - Comprehensive logging showing repository discovery, matching, and assignment +- New module `repository_cost_center_manager.py` for repository-based assignment logic +- New GitHub API methods for custom properties: + - `get_org_custom_properties()`: Fetch organization custom property schema + - `get_org_repositories_with_properties()`: List repositories with their property values (paginated) + - `get_all_org_repositories_with_properties()`: Automatic pagination wrapper + - `get_repository_custom_properties()`: Get properties for a specific repository + - `add_repositories_to_cost_center()`: Batch assign repositories to cost centers +- Configuration support for repository mode in `config_manager.py` + - New `github.cost_centers.mode` setting (supports "users", "teams", or "repository") + - New `github.cost_centers.repository_config` section with validation + - Explicit mappings configuration with property name and value matching +- Documentation for repository mode in README.md with examples +- Detailed design document in `REPOSITORY_COST_CENTER_DESIGN.md` - **GitHub Enterprise Data Resident Support**: Full support for enterprises running on GitHub Enterprise Data Resident (GHE.com) with custom API endpoints +- **Enhanced Budget System**: Product-agnostic budget creation with multi-product support + - `create_product_budget()`: Generic method for creating budgets for any product (Actions, Copilot, Packages, etc.) + - `check_cost_center_has_product_budget()`: Check for existing budgets before creation + - Configurable budget amounts (no longer hardcoded to $0) + - Support for both ProductPricing (Actions) and SkuPricing (Copilot) budget types + - Product registry system for easy extension to new products +- **Budget Configuration System**: YAML-based budget configuration + - `budgets.enabled`: Global budget creation toggle + - `budgets.products`: Per-product configuration with amounts and enable/disable flags + - Support for Copilot PRU and Actions budgets with different default amounts + - Extensible design for future products (Packages, Codespaces, etc.) +- Repository mode now supports budget creation with `--create-budgets` flag - New configuration option `github.api_base_url` in config files for custom API endpoints - New environment variable `GITHUB_API_BASE_URL` for custom API endpoint configuration - Automatic API URL validation with support for: @@ -19,6 +50,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated documentation in README.md, config.example.yaml, and .env.example ### Changed +- Updated `main.py` to support three operational modes (PRU-based, Teams-based, Repository-based) +- Renamed "Two Operational Modes" to "Three Operational Modes" in documentation - `GitHubCopilotManager` now uses configurable API base URL instead of hardcoded value - URL validation and normalization in `ConfigManager` to ensure proper API endpoint formatting diff --git a/README.md b/README.md index c9a685c..ae848d2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **tl;dr:** - Automate cost center creation and syncing with enterprise teams, org-based teams, or for every Copilot user in your enterprise - Configure Actions workflow to keep cost centers in sync -- Coming soon: automatically create budgets for each cost center +- Automatically create budgets for cost centers (Copilot PRU, Actions, and future products) Automate GitHub Copilot license cost center assignments for your enterprise with two powerful modes: @@ -88,7 +88,7 @@ Set up GitHub Actions for automatic syncing every 6 hours - see [Automation](#au ## Features -### Two Operational Modes +### Three Operational Modes **PRU-Based Mode** (Default) - Simple two-tier model: PRU overages allowed/not allowed @@ -103,6 +103,13 @@ Set up GitHub Actions for automatic syncing every 6 hours - see [Automation](#au - Full sync mode (removes users who left teams) - Single assignment (existing cost center assignments are preserved by default) +**Repository-Based Mode** (New!) +- Assign repositories to cost centers based on custom properties +- Flexible explicit mapping: map property values to cost centers +- Works with any custom property (team, service, environment, etc.) +- Automatic cost center creation for new mappings +- Perfect for aligning repository ownership with cost tracking + ### Additional Features - 🔄 **Plan/Apply execution**: Preview changes before applying - 📊 **Enhanced logging**: Real-time success/failure tracking @@ -160,6 +167,122 @@ teams: - Organization scope: `[org team] {org-name}/{team-name}` - Enterprise scope: `[enterprise team] {team-name}` +### Repository-Based Mode Configuration + +Assign repositories to cost centers based on custom properties. This mode is ideal for tracking costs by project, service, or team ownership. + +**Prerequisites:** +1. Configure custom properties in your organization settings +2. Assign property values to your repositories +3. Map property values to cost centers in config + +**Example Configuration:** + +```yaml +github: + cost_centers: + mode: "repository" # Enable repository mode + + repository_config: + explicit_mappings: + # Map repositories by team property + - cost_center: "Platform Engineering" + property_name: "team" + property_values: + - "platform" + - "infrastructure" + - "devops" + + # Map by environment + - cost_center: "Production Services" + property_name: "environment" + property_values: + - "production" + + # Map by service type + - cost_center: "Data & Analytics" + property_name: "team" + property_values: + - "data" + - "analytics" + - "ml" + +teams: + organizations: + - "your-org-name" # Required for repository mode +``` + +**Usage:** + +```bash +# Plan mode: See what would be assigned +python main.py --assign-cost-centers --mode plan + +# Apply mode: Make the assignments +python main.py --assign-cost-centers --mode apply --yes + +# With budget creation +python main.py --assign-cost-centers --mode apply --create-budgets --yes +``` + +### Budget Configuration (Optional) + +Automatically create budgets for cost centers when they're created. Supports multiple GitHub products with configurable amounts. + +```yaml +budgets: + enabled: true # Global toggle for budget creation + + products: + copilot: + amount: 100 # Budget amount in USD + enabled: true # Create Copilot PRU budgets + + actions: + amount: 125 # Budget amount in USD + enabled: true # Create Actions budgets + + # Future products + # packages: + # amount: 50 + # enabled: false +``` + +**Usage with Budgets:** + +```bash +# Any mode with budget creation +python main.py --create-budgets [other-options] + +# Teams mode with budgets +python main.py --teams-mode --create-budgets --mode apply --yes + +# Repository mode with budgets +python main.py --assign-cost-centers --mode apply --create-budgets --yes +``` + +**Supported Products:** +- **Copilot**: GitHub Copilot PRU (Premium Request Units) budgets +- **Actions**: GitHub Actions compute minutes budgets +- **Future**: Packages, Codespaces, and other products (configurable) + +**Budget Types:** +- Actions uses `ProductPricing` budget type +- Copilot uses `SkuPricing` budget type +- Automatically handles different API formats per product + +**How It Works:** +1. Fetches all repositories in your organization with their custom properties +2. For each mapping, finds repositories with matching property values +3. Creates cost centers if they don't exist (automatically) +4. Assigns matching repositories to their designated cost centers + +**Common Use Cases:** +- **By Team**: Map `team` property values to team-specific cost centers +- **By Environment**: Separate production vs. development repository costs +- **By Service**: Group microservices, frontend, backend, data services +- **By Department**: Map organizational units to cost centers + ### Environment Variables Set these instead of config file values: diff --git a/REPOSITORY_COST_CENTER_DESIGN.md b/REPOSITORY_COST_CENTER_DESIGN.md new file mode 100644 index 0000000..3279ad7 --- /dev/null +++ b/REPOSITORY_COST_CENTER_DESIGN.md @@ -0,0 +1,344 @@ +# Repository Cost Center Assignment - Design Document + +## Overview + +This feature enables automatic assignment of repositories to cost centers based on repository custom properties. This complements the existing user-based and team-based assignment modes. + +## API Endpoints Required + +### Organization-Level Custom Properties +- `GET /orgs/{org}/properties/schema` - Get all custom property definitions +- `GET /orgs/{org}/properties/values` - List all repositories with their custom property values + +### Repository-Level Custom Properties +- `GET /repos/{owner}/{repo}/properties/values` - Get custom properties for a specific repo + +### Permissions Required +- **Read**: "Custom properties" organization permissions (read) +- **Write**: "Metadata" repository permissions (read) to add repos to cost centers + +## Implementation Approach + +### Mode 1: Auto-Discovery Mode (Recommended) +Automatically discover repositories based on a specific custom property and map values to cost centers. + +**Configuration Example:** +```yaml +github: + cost_centers: + mode: repository # New mode + repository_config: + property_name: "cost-center" # The custom property to use + auto_create_cost_centers: true # Create cost centers if they don't exist + prefix: "" # Optional prefix for cost center names + + # Optional: Manual overrides for specific property values + property_value_mapping: + "engineering": "Engineering Team" + "marketing": "Marketing Department" +``` + +**Behavior:** +1. Fetch all repositories in the organization with their custom properties +2. Filter repositories that have the specified property (e.g., "cost-center") +3. Group repositories by property value +4. For each unique property value: + - Check if cost center exists (or create if `auto_create_cost_centers: true`) + - Add all repositories with that value to the cost center + +**Example:** +- Repository `app-frontend` has property `cost-center: "engineering"` +- Repository `app-backend` has property `cost-center: "engineering"` +- Repository `website` has property `cost-center: "marketing"` + +Results in: +- Cost center "engineering" (or "Engineering Team" if mapped) with 2 repos +- Cost center "marketing" (or "Marketing Department" if mapped) with 1 repo + +### Mode 2: Explicit Mapping Mode +Manually define which property values map to which cost centers. + +**Configuration Example:** +```yaml +github: + cost_centers: + mode: repository + repository_config: + property_name: "team" + explicit_mappings: + - cost_center: "Platform Engineering" + property_values: ["platform", "infrastructure", "devops"] + + - cost_center: "Product Development" + property_values: ["frontend", "backend", "mobile"] + + - cost_center: "Data Science" + property_values: ["ml", "analytics", "data"] +``` + +**Behavior:** +1. For each explicit mapping: + - Get the cost center by name (create if doesn't exist) + - Fetch all repositories with property values in the mapping list + - Add repositories to the cost center + +### Mode 3: Query-Based Mode (Advanced) +Use GitHub's repository search query syntax to find repositories. + +**Configuration Example:** +```yaml +github: + cost_centers: + mode: repository + repository_config: + cost_center_queries: + - cost_center: "Production Services" + query: "custom_properties:environment:production" + + - cost_center: "Development Services" + query: "custom_properties:environment:development custom_properties:environment:staging" +``` + +**Note:** The `repository_query` parameter in `/orgs/{org}/properties/values` accepts GitHub search qualifiers. + +## Technical Implementation + +### 1. New Module: `src/repository_cost_center_manager.py` + +```python +class RepositoryCostCenterManager: + """Manages cost center assignments based on repository custom properties.""" + + def __init__(self, config, github_api): + self.config = config + self.github_api = github_api + self.logger = logging.getLogger(__name__) + + def get_all_repository_properties(self, org: str) -> list: + """Fetch all repositories with their custom property values.""" + # GET /orgs/{org}/properties/values (paginated) + + def get_repository_properties(self, owner: str, repo: str) -> list: + """Get custom properties for a specific repository.""" + # GET /repos/{owner}/{repo}/properties/values + + def get_custom_property_schema(self, org: str) -> list: + """Get all custom property definitions for the organization.""" + # GET /orgs/{org}/properties/schema + + def auto_discover_mode(self, org: str): + """Auto-discover repositories by property and create/update cost centers.""" + # Implementation for Mode 1 + + def explicit_mapping_mode(self, org: str): + """Map repositories to cost centers using explicit mappings.""" + # Implementation for Mode 2 + + def query_based_mode(self, org: str): + """Find repositories using search queries and assign to cost centers.""" + # Implementation for Mode 3 + + def assign_repositories_to_cost_center(self, cost_center_id: str, repositories: list): + """Assign multiple repositories to a cost center.""" + # Batch repository assignment +``` + +### 2. Extend `src/github_api.py` + +Add new methods for custom properties: + +```python +def get_org_custom_properties(self, org: str) -> dict: + """Get all custom property definitions for an organization.""" + url = f"{self.base_url}/orgs/{org}/properties/schema" + return self._make_request("GET", url) + +def get_org_repositories_with_properties(self, org: str, page: int = 1, per_page: int = 100, query: str = None) -> dict: + """Get all repositories with their custom property values.""" + url = f"{self.base_url}/orgs/{org}/properties/values" + params = {"page": page, "per_page": per_page} + if query: + params["repository_query"] = query + return self._make_request("GET", url, params=params) + +def get_repository_custom_properties(self, owner: str, repo: str) -> dict: + """Get custom properties for a specific repository.""" + url = f"{self.base_url}/repos/{owner}/{repo}/properties/values" + return self._make_request("GET", url) +``` + +### 3. Update `main.py` + +Add support for repository mode: + +```python +if config.github_cost_centers_mode == 'repository': + logger.info("Running in repository mode...") + from src.repository_cost_center_manager import RepositoryCostCenterManager + + repo_manager = RepositoryCostCenterManager(config, github_api) + + # Determine which sub-mode based on config + repo_config = config.github_cost_centers_repository_config + + if hasattr(repo_config, 'property_name') and not hasattr(repo_config, 'explicit_mappings'): + repo_manager.auto_discover_mode(org_name) + elif hasattr(repo_config, 'explicit_mappings'): + repo_manager.explicit_mapping_mode(org_name) + elif hasattr(repo_config, 'cost_center_queries'): + repo_manager.query_based_mode(org_name) + else: + logger.error("Invalid repository mode configuration") +``` + +### 4. Configuration Schema Updates + +Update `config/config.example.yaml`: + +```yaml +github: + cost_centers: + # Mode can be: 'users' (default), 'teams', or 'repository' + mode: repository + + # Configuration for repository mode + repository_config: + # Auto-discovery mode: Uses a single property to group repositories + property_name: "cost-center" + auto_create_cost_centers: true + prefix: "" # Optional prefix for cost center names (e.g., "Team " -> "Team Engineering") + + # Optional: Map property values to specific cost center names + property_value_mapping: + "eng": "Engineering" + "product": "Product Development" + + # Explicit mapping mode: Define exact mappings + # Uncomment to use this mode instead of auto-discovery + # explicit_mappings: + # - cost_center: "Platform Engineering" + # property_values: ["platform", "infrastructure", "devops"] + # + # - cost_center: "Product Development" + # property_values: ["frontend", "backend", "mobile"] + + # Query-based mode: Use GitHub search queries + # Uncomment to use this mode instead of auto-discovery + # cost_center_queries: + # - cost_center: "Production Services" + # query: "custom_properties:environment:production" + # + # - cost_center: "Development Services" + # query: "custom_properties:environment:development" +``` + +## Data Flow + +### Auto-Discovery Mode Flow + +``` +1. GET /orgs/{org}/properties/values + → Returns: [ + { + repository_id: 123, + repository_name: "app-frontend", + properties: [ + {property_name: "cost-center", value: "engineering"}, + {property_name: "team", value: "platform"} + ] + }, + ... + ] + +2. Filter repositories by configured property_name ("cost-center") + +3. Group repositories by property value: + engineering: [app-frontend, app-backend] + marketing: [website, landing-page] + +4. For each group: + a. Check if cost center exists (or create) + b. POST /copilot/usage/cost_centers/{cost_center_id}/repositories + with batch of repository IDs +``` + +## Error Handling + +1. **Missing Custom Property**: Skip repositories that don't have the configured property +2. **Invalid Property Value**: Log warning and skip +3. **Cost Center Creation Failure**: If `auto_create_cost_centers: false`, log error and skip +4. **Repository Assignment Failure**: Log error but continue with other repositories +5. **API Rate Limiting**: Implement exponential backoff and retry logic + +## Logging Strategy + +``` +INFO: Found 150 repositories in organization 'acme-corp' +INFO: Filtering repositories with custom property 'cost-center' +INFO: Found 120 repositories with cost-center property +INFO: Discovered 5 unique cost center values: engineering, marketing, sales, support, operations +INFO: Processing cost center 'engineering' with 45 repositories +INFO: Cost center 'engineering' already exists (ID: cc_abc123) +INFO: Adding 45 repositories to cost center 'engineering' +INFO: Successfully added 45 repositories to cost center 'engineering' +INFO: Processing cost center 'marketing' with 30 repositories +WARNING: Cost center 'marketing' does not exist and auto_create is disabled, skipping +... +``` + +## Testing Strategy + +1. **Unit Tests**: Mock API responses for custom properties endpoints +2. **Integration Tests**: Test with actual GitHub API (test organization) +3. **Test Scenarios**: + - Repositories with different property values + - Repositories missing the configured property + - Empty organization (no repositories) + - Large organization (pagination handling) + - Cost centers that don't exist (auto-create enabled/disabled) + +## Migration Path + +Users currently using `users` or `teams` mode can migrate to `repository` mode by: + +1. Setting up custom properties in their organization +2. Assigning property values to repositories +3. Updating configuration to use `mode: repository` +4. Running the tool to validate assignments +5. Switching over completely + +## Future Enhancements + +1. **Hybrid Mode**: Combine repository + team assignments in a single run +2. **Scheduled Property Sync**: Watch for repository property changes via webhooks +3. **Budget Allocation by Repository Count**: Auto-calculate budgets based on number of repos +4. **Property Validation**: Validate that repositories have required properties before assignment +5. **Dry-Run Mode**: Preview what changes would be made without applying them + +## Implementation Priority + +**Phase 1 (MVP)**: +- [ ] Extend `github_api.py` with custom properties endpoints +- [ ] Create `repository_cost_center_manager.py` with auto-discovery mode +- [ ] Update configuration schema +- [ ] Update `main.py` to support repository mode +- [ ] Basic logging and error handling + +**Phase 2 (Enhanced)**: +- [ ] Add explicit mapping mode +- [ ] Add property value mapping feature +- [ ] Comprehensive error handling +- [ ] Unit tests + +**Phase 3 (Advanced)**: +- [ ] Query-based mode +- [ ] Dry-run mode +- [ ] Documentation and examples +- [ ] Integration tests with real API + +## Open Questions + +1. **Batch Size**: What's the optimal batch size for repository assignments? (GitHub allows max 30 repos per batch in some endpoints) +2. **Pagination**: Should we fetch all repositories at once or paginate? (Large orgs may have thousands of repos) +3. **Caching**: Should we cache custom property schemas to reduce API calls? +4. **Conflict Resolution**: What happens if a repository is already in a different cost center? diff --git a/config/config.example.yaml b/config/config.example.yaml index 31fb2ff..6279c66 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -15,6 +15,71 @@ github: # # Leave commented or set to null to use standard GitHub.com API # api_base_url: null + + # Cost Center Assignment Mode + # Determines how repositories/users are assigned to cost centers + # Options: 'users', 'teams', or 'repository' + cost_centers: + mode: "users" # Default: user-based assignment (PRU exceptions) + + # ======================================== + # Repository Mode Configuration + # ======================================== + # Use this mode to assign repositories to cost centers based on custom properties. + # Requires: Organization custom properties to be configured in your GitHub organization. + # + # To enable repository mode: + # 1. Set mode: "repository" above + # 2. Configure explicit_mappings below + # 3. Ensure repositories have the custom properties you're mapping + # + # Example custom property setup (in GitHub UI): + # Property Name: "team" + # Property Type: string or single_select + # Values: "platform", "frontend", "backend", "data", etc. + # + repository_config: + # Explicit Mapping Mode (Recommended) + # Map specific custom property values to cost centers. + # This mode is most flexible when organizations use generic properties like "team", "service", or "environment" + # + explicit_mappings: + # Example 1: Map repositories with team property to cost centers + - cost_center: "Platform Engineering" + property_name: "team" # Name of the custom property to check + property_values: # Repositories with any of these values will be assigned + - "platform" + - "infrastructure" + - "devops" + + # Example 2: Assign production services to a specific cost center + - cost_center: "Production Services" + property_name: "environment" + property_values: + - "production" + + # Example 3: Group by service type + - cost_center: "Frontend Applications" + property_name: "service" + property_values: + - "web" + - "mobile" + - "ui" + + # Example 4: Data team repositories + - cost_center: "Data & Analytics" + property_name: "team" + property_values: + - "data" + - "analytics" + - "ml" + + # NOTES: + # - If a cost center doesn't exist, it will be created automatically + # - Repositories can match multiple mappings (they'll be added to multiple cost centers) + # - Repositories without the specified property will be skipped + # - Property names are case-sensitive and must match exactly + # - Property values are also case-sensitive # Logging Configuration logging: @@ -70,4 +135,33 @@ teams: team_mappings: {} # "my-org/frontend-team": "CC-FRONTEND-001" # "my-org/backend-team": "CC-BACKEND-001" - # "other-org/devops-team": "Team: DevOps" # Will be auto-created if auto_create is true \ No newline at end of file + # "other-org/devops-team": "Team: DevOps" # Will be auto-created if auto_create is true + +# ======================================== +# Budget Configuration (Optional) +# ======================================== +# Configure automatic budget creation for cost centers. +# Requires GitHub Enterprise with Budgets API access. +# Use with --create-budgets flag. +budgets: + enabled: false # Set to true to enable automatic budget creation + + # Product-specific budget configuration + # Supported products: copilot, actions, packages, codespaces + products: + copilot: + amount: 100 # Budget amount in USD + enabled: true # Create Copilot PRU budgets + + actions: + amount: 125 # Budget amount in USD + enabled: true # Create Actions budgets + + # Future products can be added here + # packages: + # amount: 50 + # enabled: false + # + # codespaces: + # amount: 200 + # enabled: false \ No newline at end of file diff --git a/main.py b/main.py index 74bb49e..907d1e4 100644 --- a/main.py +++ b/main.py @@ -27,6 +27,7 @@ from src.github_api import GitHubCopilotManager from src.cost_center_manager import CostCenterManager from src.teams_cost_center_manager import TeamsCostCenterManager +from src.repository_cost_center_manager import RepositoryCostCenterManager from src.config_manager import ConfigManager from src.logger_setup import setup_logging @@ -467,7 +468,80 @@ def main(): # Initialize GitHub manager github_manager = GitHubCopilotManager(config) - # Check if teams mode is enabled (via flag or config) + # Check operation mode: repository, teams, or PRU-based (default) + cost_center_mode = getattr(config, 'github_cost_centers_mode', 'users') + + # Repository mode: Assign repositories to cost centers based on custom properties + if cost_center_mode == "repository": + logger.info("=" * 80) + logger.info("REPOSITORY MODE ENABLED") + logger.info("=" * 80) + + if not hasattr(config, 'github_cost_centers_repository_config'): + logger.error("Repository mode requires 'repository_config' in config.github.cost_centers") + sys.exit(1) + + # Validate that an organization name is available + # For repository mode, we need an org context - use first teams org or enterprise + org_name = None + if hasattr(config, 'teams_organizations') and config.teams_organizations: + org_name = config.teams_organizations[0] + logger.info(f"Using organization: {org_name}") + elif config.github_enterprise: + # For enterprise, we'd need to list orgs - for now require explicit config + logger.error( + "Repository mode requires an organization name. " + "Please add 'organizations: [\"your-org-name\"]' to config.teams.organizations" + ) + sys.exit(1) + + # Initialize repository manager + repo_manager = RepositoryCostCenterManager(config, github_manager, create_budgets=args.create_budgets) + + # Handle show-config + if args.show_config: + logger.info("=" * 60) + logger.info("Repository Mode Configuration") + logger.info("=" * 60) + logger.info(f"Organization: {org_name}") + logger.info(f"Explicit Mappings: {len(config.github_cost_centers_repository_config.explicit_mappings)}") + logger.info("\nMappings:") + for idx, mapping in enumerate(config.github_cost_centers_repository_config.explicit_mappings, 1): + logger.info(f"\n {idx}. Cost Center: {mapping.get('cost_center')}") + logger.info(f" Property: {mapping.get('property_name')}") + logger.info(f" Values: {mapping.get('property_values')}") + logger.info("=" * 60) + + if not any([args.list_users, args.assign_cost_centers]): + return + + # Handle assignment + if args.assign_cost_centers: + if args.mode == "plan": + logger.info("MODE=plan: Would assign repositories to cost centers (dry-run)") + logger.info("Run with --mode apply to make actual changes") + # TODO: Implement dry-run mode in repository manager + return + elif args.mode == "apply": + if not args.yes: + response = input("\nThis will assign repositories to cost centers. Continue? (yes/no): ") + if response.lower() != "yes": + logger.info("Operation cancelled by user") + return + + logger.info("MODE=apply: Assigning repositories to cost centers...") + summary = repo_manager.run(org_name) + + logger.info("Repository assignment completed!") + return + else: + logger.error(f"Invalid mode: {args.mode}. Use 'plan' or 'apply'") + sys.exit(1) + else: + logger.info("No action specified. Use --assign-cost-centers to assign repositories") + return + + # Teams mode: Check if teams mode is enabled (via flag or config) teams_mode_enabled = args.teams_mode or config.teams_enabled if teams_mode_enabled: diff --git a/src/config_manager.py b/src/config_manager.py index d859344..06c9fad 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -12,6 +12,8 @@ from dotenv import load_dotenv from urllib.parse import urlparse +from src.config_models import RepositoryConfig + class ConfigManager: """Manages application configuration from files and environment variables.""" @@ -118,6 +120,21 @@ def _load_config(self): # Incremental processing configuration self.enable_incremental = cost_center_config.get("enable_incremental", False) + + # Cost center assignment mode configuration + # Supports: 'users' (default), 'teams', or 'repository' + github_cost_centers_config = config_data.get("github", {}).get("cost_centers", {}) + self.github_cost_centers_mode = github_cost_centers_config.get("mode", "users") + + # Repository mode configuration + if self.github_cost_centers_mode == "repository": + repo_config_data = github_cost_centers_config.get("repository_config", {}) + self.github_cost_centers_repository_config = RepositoryConfig(repo_config_data) + + self.logger.info( + f"Repository mode enabled with {len(self.github_cost_centers_repository_config.explicit_mappings)} " + f"explicit mapping(s)" + ) # Teams integration configuration @@ -134,6 +151,14 @@ def _load_config(self): teams_config.get("remove_orphaned_users", True) # Fallback to old key ) + # Budget configuration + budget_config = config_data.get("budgets", {}) + self.budgets_enabled = budget_config.get("enabled", False) + self.budget_products = budget_config.get("products", { + "copilot": {"amount": 100, "enabled": True}, + "actions": {"amount": 125, "enabled": True} + }) + # Store full config for other methods self.config = config_data diff --git a/src/config_models.py b/src/config_models.py new file mode 100644 index 0000000..d010431 --- /dev/null +++ b/src/config_models.py @@ -0,0 +1,39 @@ +""" +Configuration model classes for cost center automation. + +These classes provide structured access to different configuration sections. +""" + + +class RepositoryConfig: + """Configuration for repository-based cost center assignment mode.""" + + def __init__(self, data: dict): + """ + Initialize repository configuration. + + Args: + data: Dictionary containing repository configuration data + + Raises: + ValueError: If configuration validation fails + """ + # Explicit mappings list + self.explicit_mappings = data.get("explicit_mappings", []) + + # Validate explicit mappings structure + for idx, mapping in enumerate(self.explicit_mappings): + if not isinstance(mapping, dict): + raise ValueError(f"Explicit mapping {idx} must be a dictionary") + + if not mapping.get("cost_center"): + raise ValueError(f"Explicit mapping {idx} missing 'cost_center' field") + + if not mapping.get("property_name"): + raise ValueError(f"Explicit mapping {idx} missing 'property_name' field") + + if not mapping.get("property_values"): + raise ValueError(f"Explicit mapping {idx} missing 'property_values' field") + + if not isinstance(mapping.get("property_values"), list): + raise ValueError(f"Explicit mapping {idx} 'property_values' must be a list") diff --git a/src/github_api.py b/src/github_api.py index 6ed7058..5c0822c 100644 --- a/src/github_api.py +++ b/src/github_api.py @@ -519,7 +519,6 @@ def list_enterprise_teams(self) -> List[Dict]: self.logger.error("Enterprise name required for listing enterprise teams") return [] - self.logger.info(f"Fetching enterprise teams for: {self.enterprise_name}") url = f"{self.base_url}/enterprises/{self.enterprise_name}/teams" all_teams = [] @@ -542,7 +541,6 @@ def list_enterprise_teams(self) -> List[Dict]: break all_teams.extend(teams) - self.logger.info(f"Fetched page {page} with {len(teams)} enterprise teams") page += 1 @@ -554,7 +552,6 @@ def list_enterprise_teams(self) -> List[Dict]: self.logger.error(f"Failed to fetch enterprise teams: {str(e)}") break - self.logger.info(f"Total enterprise teams found: {len(all_teams)}") return all_teams def get_enterprise_team_members(self, team_slug: str) -> List[Dict]: @@ -610,7 +607,6 @@ def get_enterprise_team_members(self, team_slug: str) -> List[Dict]: self.logger.warning(f"Failed to fetch members for enterprise team {team_slug}: {str(e)}") break - self.logger.info(f"Total members found in enterprise team {team_slug}: {len(all_members)}") return all_members def get_all_active_cost_centers(self) -> Dict[str, str]: @@ -1090,7 +1086,7 @@ def check_cost_center_has_budget(self, cost_center_id: str, cost_center_name: st self.logger.warning(f"Failed to check budget for cost center '{cost_center_name}' (ID: {cost_center_id}): {str(e)}") return False - def create_cost_center_budget(self, cost_center_id: str, cost_center_name: str) -> bool: + def create_cost_center_budget(self, cost_center_id: str, cost_center_name: str, budget_amount: int = 100) -> bool: """ Create a budget for a cost center using the GitHub Enterprise Budgets API. @@ -1100,6 +1096,7 @@ def create_cost_center_budget(self, cost_center_id: str, cost_center_name: str) Args: cost_center_id: UUID of the cost center to create a budget for cost_center_name: Name of the cost center (used for logging only) + budget_amount: Budget amount in dollars (default: 100) Returns: True if budget was created successfully, False otherwise @@ -1126,7 +1123,7 @@ def create_cost_center_budget(self, cost_center_id: str, cost_center_name: str) "budget_type": "SkuPricing", "budget_product_sku": "copilot_premium_request", "budget_scope": "cost_center", - "budget_amount": 0, + "budget_amount": budget_amount, "prevent_further_usage": True, "budget_entity_name": cost_center_id, # Use UUID instead of name "budget_alerting": { @@ -1153,4 +1150,397 @@ def create_cost_center_budget(self, cost_center_id: str, cost_center_name: str) return False except requests.exceptions.RequestException as e: self.logger.error(f"Failed to create budget for cost center '{cost_center_name}' (ID: {cost_center_id}): {str(e)}") + return False + + def check_cost_center_has_product_budget(self, cost_center_id: str, cost_center_name: str, product: str) -> bool: + """ + Check if a cost center already has a budget for a specific product. + + Args: + cost_center_id: UUID of the cost center to check + cost_center_name: Name of the cost center to check + product: Product name (e.g., 'actions', 'copilot') + + Returns: + True if a budget already exists for this cost center and product, False otherwise + """ + if not self.use_enterprise or not self.enterprise_name: + self.logger.warning("Budget checking only available for GitHub Enterprise") + return False + + url = f"{self.base_url}/enterprises/{self.enterprise_name}/settings/billing/budgets" + + try: + budgets = self._make_request(url) + + # Get the product SKU for comparison + _, product_sku = self._get_budget_type_and_sku(product) + + # Check if budget exists for this cost center and product + if self._budget_exists_for_cost_center(budgets, cost_center_id, product_sku): + self.logger.info(f"Found existing {product} budget for cost center: {cost_center_name}") + return True + + return False + + except requests.exceptions.RequestException as e: + self.logger.warning(f"Failed to check {product} budget for cost center '{cost_center_name}' (ID: {cost_center_id}): {str(e)}") + return False + + def create_product_budget(self, cost_center_id: str, cost_center_name: str, product: str, amount: int) -> bool: + """ + Create a product-level budget for a cost center. + + Args: + cost_center_id: UUID of the cost center + cost_center_name: Name of the cost center (for logging) + product: Product name (e.g., 'actions', 'copilot') + amount: Budget amount in dollars + + Returns: + True if budget was created successfully, False otherwise + """ + if not self.use_enterprise or not self.enterprise_name: + self.logger.error("Budget creation only available for GitHub Enterprise") + return False + + # Check if budget already exists + if self.check_cost_center_has_product_budget(cost_center_id, cost_center_name, product): + self.logger.info(f"{product.title()} budget already exists for cost center: {cost_center_name}") + return True + + url = f"{self.base_url}/enterprises/{self.enterprise_name}/settings/billing/budgets" + + # Determine budget type and product SKU based on the product + budget_type, product_sku = self._get_budget_type_and_sku(product) + + payload = { + "budget_type": budget_type, + "budget_product_sku": product_sku, + "budget_scope": "cost_center", + "budget_amount": amount, + "prevent_further_usage": True, + "budget_entity_name": cost_center_id, # Use UUID + "budget_alerting": { + "will_alert": False, + "alert_recipients": [] + } + } + + headers = { + "accept": "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + "content-type": "application/json" + } + + try: + response = self._make_request(url, method='POST', json=payload, custom_headers=headers) + self.logger.info(f"✅ Successfully created ${amount} {product} budget for cost center: {cost_center_name}") + return True + + except requests.exceptions.RequestException as e: + self.logger.error(f"❌ Failed to create {product} budget for cost center '{cost_center_name}': {str(e)}") + return False + + def _get_budget_type_and_sku(self, product: str) -> tuple[str, str]: + """ + Get the appropriate budget type and product SKU for a given product name. + + GitHub supports two types of budgets: + 1. ProductPricing: Budgets that track spending across an entire product + 2. SkuPricing: Budgets that track spending for a specific SKU within a product + + Args: + product: Product name or SKU (e.g., 'actions', 'copilot', 'copilot_premium_request') + + Returns: + Tuple of (budget_type, product_sku) + + Reference: + https://docs.github.com/en/enterprise-cloud@latest/billing/reference/product-and-sku-names + """ + product_lower = product.lower() + + # Product-level identifiers (ProductPricing budgets) + # These track spending across the entire product + product_level = { + 'actions': 'actions', + 'packages': 'packages', + 'codespaces': 'codespaces', + 'copilot': 'copilot', + 'ghas': 'ghas', + 'ghec': 'ghec' + } + + # SKU-level identifiers (SkuPricing budgets) + # These track spending for specific SKUs within a product + # Common SKUs users might want to budget for + sku_level = { + # GitHub Copilot SKUs + 'copilot_premium_request': 'copilot_premium_request', + 'copilot_agent_premium_request': 'copilot_agent_premium_request', + 'copilot_enterprise': 'copilot_enterprise', + 'copilot_for_business': 'copilot_for_business', + 'copilot_standalone': 'copilot_standalone', + + # GitHub Actions SKUs (common examples) + 'actions_linux': 'actions_linux', + 'actions_macos': 'actions_macos', + 'actions_windows': 'actions_windows', + 'actions_storage': 'actions_storage', + + # GitHub Codespaces SKUs + 'codespaces_storage': 'codespaces_storage', + 'codespaces_prebuild_storage': 'codespaces_prebuild_storage', + + # GitHub Packages SKUs + 'packages_storage': 'packages_storage', + 'packages_bandwidth': 'packages_bandwidth', + + # GitHub Advanced Security SKUs + 'ghas_licenses': 'ghas_licenses', + 'ghas_code_security_licenses': 'ghas_code_security_licenses', + 'ghas_secret_protection_licenses': 'ghas_secret_protection_licenses', + + # Other SKUs + 'ghec_licenses': 'ghec_licenses', + 'git_lfs_storage': 'git_lfs_storage', + 'git_lfs_bandwidth': 'git_lfs_bandwidth', + 'models_inference': 'models_inference', + 'spark_premium_request': 'spark_premium_request' + } + + # Check if it's a known SKU-level identifier + if product_lower in sku_level: + return ("SkuPricing", sku_level[product_lower]) + + # Check if it's a known product-level identifier + if product_lower in product_level: + return ("ProductPricing", product_level[product_lower]) + + # Default: assume it's a custom SKU and use SkuPricing + # This allows flexibility for new SKUs not yet in our list + self.logger.warning( + f"Unknown product/SKU '{product}'. Defaulting to SkuPricing. " + f"See https://docs.github.com/en/enterprise-cloud@latest/billing/reference/product-and-sku-names" + ) + return ("SkuPricing", product_lower) + + def _budget_exists_for_cost_center(self, budgets: List[Dict], cost_center_id: str, product_sku: str) -> bool: + """ + Check if a budget exists for a specific cost center and product SKU. + + Args: + budgets: List of budget dictionaries to search through + cost_center_id: UUID of the cost center to check + product_sku: Product SKU to match + + Returns: + True if a matching budget exists, False otherwise + """ + for budget in budgets: + if (budget.get('budget_scope') == 'cost_center' and + budget.get('budget_entity_name') == cost_center_id and + budget.get('budget_product_sku') == product_sku): + return True + return False + + # =========================== + # Custom Properties API Methods + # =========================== + + def get_org_custom_properties(self, org: str) -> List[Dict]: + """Get all custom property definitions for an organization. + + Args: + org: Organization name + + Returns: + List of custom property definitions with their schemas + + Example response: + [ + { + "property_name": "environment", + "value_type": "single_select", + "required": true, + "default_value": "production", + "allowed_values": ["production", "development"] + }, + ... + ] + """ + url = f"{self.base_url}/orgs/{org}/properties/schema" + self.logger.info(f"Fetching custom property schema for organization: {org}") + + try: + properties = self._make_request(url) + self.logger.info(f"Found {len(properties)} custom properties defined for organization: {org}") + return properties + except requests.exceptions.RequestException as e: + self.logger.error(f"Failed to fetch custom properties for organization '{org}': {str(e)}") + return [] + + def get_org_repositories_with_properties(self, org: str, page: int = 1, per_page: int = 100, + query: Optional[str] = None) -> Dict: + """Get repositories with their custom property values for an organization. + + Args: + org: Organization name + page: Page number for pagination (default: 1) + per_page: Results per page, max 100 (default: 100) + query: Optional repository search query using GitHub search syntax + Example: "custom_properties:environment:production" + + Returns: + Dict containing repository list with their custom properties + + Example response: + [ + { + "repository_id": 1296269, + "repository_name": "Hello-World", + "repository_full_name": "octocat/Hello-World", + "properties": [ + {"property_name": "environment", "value": "production"}, + {"property_name": "team", "value": "platform"} + ] + }, + ... + ] + """ + url = f"{self.base_url}/orgs/{org}/properties/values" + params = {"page": page, "per_page": per_page} + + if query: + params["repository_query"] = query + self.logger.info(f"Fetching repositories for organization '{org}' with query: {query} (page {page})") + else: + self.logger.info(f"Fetching repositories with custom properties for organization: {org} (page {page})") + + try: + repositories = self._make_request(url, params=params) + self.logger.debug(f"Fetched {len(repositories)} repositories from page {page}") + return repositories + except requests.exceptions.RequestException as e: + self.logger.error(f"Failed to fetch repositories with properties for organization '{org}': {str(e)}") + return [] + + def get_all_org_repositories_with_properties(self, org: str, query: Optional[str] = None) -> List[Dict]: + """Get all repositories with their custom property values (handles pagination automatically). + + Args: + org: Organization name + query: Optional repository search query using GitHub search syntax + + Returns: + List of all repositories with their custom properties + """ + all_repositories = [] + page = 1 + per_page = 100 + + while True: + repositories = self.get_org_repositories_with_properties(org, page, per_page, query) + + if not repositories: + break + + all_repositories.extend(repositories) + + # Check if we have more pages + if len(repositories) < per_page: + break + + page += 1 + + self.logger.info(f"Total repositories with custom properties found: {len(all_repositories)}") + return all_repositories + + def get_repository_custom_properties(self, owner: str, repo: str) -> List[Dict]: + """Get custom properties for a specific repository. + + Args: + owner: Repository owner (organization or user) + repo: Repository name + + Returns: + List of custom property name-value pairs + + Example response: + [ + {"property_name": "environment", "value": "production"}, + {"property_name": "team", "value": "platform"} + ] + """ + url = f"{self.base_url}/repos/{owner}/{repo}/properties/values" + self.logger.debug(f"Fetching custom properties for repository: {owner}/{repo}") + + try: + properties = self._make_request(url) + return properties + except requests.exceptions.RequestException as e: + self.logger.error(f"Failed to fetch custom properties for repository '{owner}/{repo}': {str(e)}") + return [] + + def add_repositories_to_cost_center(self, cost_center_id: str, repository_names: List[str]) -> bool: + """Add multiple repositories to a specific cost center. + + Args: + cost_center_id: Target cost center ID (UUID) + repository_names: List of repository full names (strings in 'org/repo' format) to add + + Returns: + True if successful, False otherwise + + Note: + The API may have a maximum number of repositories per request. + Currently supporting batch assignment similar to user assignment. + """ + if not self.use_enterprise or not self.enterprise_name: + self.logger.warning("Cost center assignment updates only available for GitHub Enterprise") + return False + + if not repository_names: + self.logger.warning("No repository names provided to add to cost center") + return False + + self.logger.info(f"Adding {len(repository_names)} repositories to cost center {cost_center_id}") + + url = f"{self.base_url}/enterprises/{self.enterprise_name}/settings/billing/cost-centers/{cost_center_id}/resource" + + payload = { + "repositories": repository_names + } + + # Set proper headers including API version + headers = { + "accept": "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + "content-type": "application/json" + } + + try: + response = self.session.post(url, json=payload, headers=headers) + + # Handle rate limiting + if response.status_code == 429: + reset_time = int(response.headers.get('X-RateLimit-Reset', time.time() + 60)) + wait_time = reset_time - int(time.time()) + 1 + self.logger.warning(f"Rate limit hit. Waiting {wait_time} seconds...") + time.sleep(wait_time) + return self.add_repositories_to_cost_center(cost_center_id, repository_ids) + + if response.status_code in [200, 201, 204]: + self.logger.info(f"✅ Successfully added {len(repository_ids)} repositories to cost center {cost_center_id}") + return True + else: + self.logger.error( + f"❌ Failed to add repositories to cost center {cost_center_id}: " + f"{response.status_code} {response.text}" + ) + return False + + except requests.exceptions.RequestException as e: + self.logger.error(f"❌ Error adding repositories to cost center {cost_center_id}: {str(e)}") return False \ No newline at end of file diff --git a/src/repository_cost_center_manager.py b/src/repository_cost_center_manager.py new file mode 100644 index 0000000..9a677e7 --- /dev/null +++ b/src/repository_cost_center_manager.py @@ -0,0 +1,360 @@ +""" +Repository Cost Center Manager for assigning repositories to cost centers based on custom properties. +""" + +import logging +from typing import Dict, List, Optional, Set + + +class RepositoryCostCenterManager: + """Manages cost center assignments based on repository custom properties.""" + + def __init__(self, config, github_api, create_budgets: bool = False): + """Initialize the repository cost center manager. + + Args: + config: Configuration object + github_api: GitHubCopilotManager instance for API calls + create_budgets: Whether to create budgets for cost centers + """ + self.config = config + self.github_api = github_api + self.create_budgets = create_budgets + self.logger = logging.getLogger(__name__) + + # Validate configuration + if not hasattr(config, 'github_cost_centers_repository_config'): + raise ValueError("Repository mode requires 'repository_config' in configuration") + + self.repo_config = config.github_cost_centers_repository_config + + def run(self, org_name: str) -> Dict[str, any]: + """Main entry point for repository-based cost center assignment. + + Args: + org_name: Organization name to process + + Returns: + Summary dictionary with assignment results + """ + self.logger.info("=" * 80) + self.logger.info("Starting repository-based cost center assignment") + self.logger.info("=" * 80) + + # Check which mode is configured + if hasattr(self.repo_config, 'explicit_mappings'): + return self.explicit_mapping_mode(org_name) + else: + raise ValueError( + "Repository mode requires 'explicit_mappings' in repository_config. " + "Please configure explicit mappings in your config file." + ) + + def explicit_mapping_mode(self, org_name: str) -> Dict[str, any]: + """Map repositories to cost centers using explicit property value mappings. + + This mode allows users to define which custom property values map to which cost centers. + For example, mapping property values ["platform", "infrastructure"] to "Platform Engineering" cost center. + + Args: + org_name: Organization name to process + + Returns: + Summary dictionary with assignment results + """ + self.logger.info("Running in EXPLICIT MAPPING mode") + self.logger.info(f"Organization: {org_name}") + + explicit_mappings = self.repo_config.explicit_mappings + if not explicit_mappings: + self.logger.error("No explicit mappings configured") + return {"error": "No explicit mappings configured"} + + self.logger.info(f"Found {len(explicit_mappings)} cost center mapping(s) to process") + + # Fetch all repositories with their custom properties + self.logger.info("Fetching all repositories with custom properties...") + all_repos = self.github_api.get_all_org_repositories_with_properties(org_name) + + if not all_repos: + self.logger.warning(f"No repositories found in organization '{org_name}'") + return {"repositories_found": 0, "assignments": []} + + self.logger.info(f"Found {len(all_repos)} repositories in organization") + + # Get all existing cost centers for the enterprise + self.logger.info("Fetching existing cost centers...") + existing_cost_centers = self.github_api.get_cost_centers() + cost_center_map = {cc['name']: cc for cc in existing_cost_centers} + self.logger.info(f"Found {len(existing_cost_centers)} existing cost centers") + + summary = { + "repositories_found": len(all_repos), + "mappings_processed": 0, + "assignments": [] + } + + # Process each explicit mapping + for mapping_idx, mapping in enumerate(explicit_mappings, 1): + self.logger.info("=" * 80) + self.logger.info(f"Processing mapping {mapping_idx}/{len(explicit_mappings)}") + + cost_center_name = mapping.get('cost_center') + property_name = mapping.get('property_name') + property_values = mapping.get('property_values', []) + + if not cost_center_name or not property_name or not property_values: + self.logger.error( + f"Invalid mapping configuration: cost_center='{cost_center_name}', " + f"property_name='{property_name}', property_values={property_values}" + ) + continue + + self.logger.info(f"Cost Center: {cost_center_name}") + self.logger.info(f"Property Name: {property_name}") + self.logger.info(f"Property Values: {property_values}") + + # Find repositories matching this mapping + matching_repos = self._find_matching_repositories( + all_repos, + property_name, + property_values + ) + + if not matching_repos: + self.logger.warning( + f"No repositories found with property '{property_name}' " + f"matching values: {property_values}" + ) + summary['assignments'].append({ + 'cost_center': cost_center_name, + 'property_name': property_name, + 'property_values': property_values, + 'repositories_matched': 0, + 'repositories_assigned': 0, + 'success': False, + 'message': 'No matching repositories found' + }) + continue + + self.logger.info(f"Found {len(matching_repos)} matching repositories") + + # Get or create cost center + cost_center = cost_center_map.get(cost_center_name) + + if not cost_center: + self.logger.info(f"Cost center '{cost_center_name}' does not exist, creating it...") + try: + cost_center = self.github_api.create_cost_center(cost_center_name) + cost_center_map[cost_center_name] = cost_center + self.logger.info( + f"Successfully created cost center: {cost_center_name} " + f"(ID: {cost_center['id']})" + ) + + # Create budgets if enabled + if self.create_budgets and hasattr(self.config, 'budgets_enabled') and self.config.budgets_enabled: + self._create_budgets_for_cost_center(cost_center['id'], cost_center_name) + except Exception as e: + self.logger.error(f"Failed to create cost center '{cost_center_name}': {str(e)}") + summary['assignments'].append({ + 'cost_center': cost_center_name, + 'property_name': property_name, + 'property_values': property_values, + 'repositories_matched': len(matching_repos), + 'repositories_assigned': 0, + 'success': False, + 'message': f'Failed to create cost center: {str(e)}' + }) + continue + else: + self.logger.info( + f"Cost center '{cost_center_name}' already exists " + f"(ID: {cost_center['id']})" + ) + + # Assign repositories to cost center + cost_center_id = cost_center['id'] + assigned_count = self._assign_repositories_to_cost_center( + cost_center_id, + cost_center_name, + matching_repos + ) + + summary['mappings_processed'] += 1 + summary['assignments'].append({ + 'cost_center': cost_center_name, + 'cost_center_id': cost_center_id, + 'property_name': property_name, + 'property_values': property_values, + 'repositories_matched': len(matching_repos), + 'repositories_assigned': assigned_count, + 'success': assigned_count > 0, + 'message': f'Successfully assigned {assigned_count}/{len(matching_repos)} repositories' + }) + + # Print summary + self.logger.info("=" * 80) + self.logger.info("REPOSITORY ASSIGNMENT SUMMARY") + self.logger.info("=" * 80) + self.logger.info(f"Total repositories in organization: {summary['repositories_found']}") + self.logger.info(f"Mappings processed: {summary['mappings_processed']}") + + for assignment in summary['assignments']: + self.logger.info("") + self.logger.info(f"Cost Center: {assignment['cost_center']}") + self.logger.info(f" Property: {assignment['property_name']}") + self.logger.info(f" Values: {assignment['property_values']}") + self.logger.info(f" Matched: {assignment['repositories_matched']} repositories") + self.logger.info(f" Assigned: {assignment['repositories_assigned']} repositories") + self.logger.info(f" Status: {'✓ Success' if assignment['success'] else '✗ Failed'}") + + self.logger.info("=" * 80) + + return summary + + def _find_matching_repositories( + self, + repositories: List[Dict], + property_name: str, + property_values: List[str] + ) -> List[Dict]: + """Find repositories that have a specific custom property with matching values. + + Args: + repositories: List of repository dictionaries with properties + property_name: Name of the custom property to match + property_values: List of acceptable values for the property + + Returns: + List of repositories that match the criteria + """ + matching_repos = [] + property_values_set = set(property_values) # Use set for O(1) lookup + + for repo in repositories: + properties = repo.get('properties', []) + + # Check if this repository has the property with a matching value + for prop in properties: + if prop.get('property_name') == property_name: + value = prop.get('value') + if value in property_values_set: + matching_repos.append(repo) + self.logger.debug( + f"Repository '{repo['repository_full_name']}' matched: " + f"{property_name}={value}" + ) + break # Found a match, move to next repository + + return matching_repos + + def _assign_repositories_to_cost_center( + self, + cost_center_id: str, + cost_center_name: str, + repositories: List[Dict] + ) -> int: + """Assign multiple repositories to a cost center. + + The GitHub API requires repository names in "org/repo" format for assignment. + + Args: + cost_center_id: UUID of the cost center + cost_center_name: Name of the cost center (for logging) + repositories: List of repository dictionaries + + Returns: + Number of repositories successfully assigned + """ + if not repositories: + return 0 + + # Extract repository names + repo_names = [] + + for repo in repositories: + repo_full_name = repo.get('repository_full_name') + + if repo_full_name: + repo_names.append(repo_full_name) + else: + repo_name = repo.get('repository_name', 'unknown') + self.logger.warning(f"Repository '{repo_name}' is missing repository_full_name, skipping") + + if not repo_names: + self.logger.error("No valid repository names found to assign") + return 0 + + self.logger.info( + f"Assigning {len(repo_names)} repositories to cost center '{cost_center_name}' " + f"(ID: {cost_center_id})" + ) + + # Log repository names for visibility + for repo_name in repo_names[:10]: # Show first 10 + self.logger.info(f" - {repo_name}") + if len(repo_names) > 10: + self.logger.info(f" ... and {len(repo_names) - 10} more") + + # Call the API to assign repositories + try: + success = self.github_api.add_repositories_to_cost_center(cost_center_id, repo_names) + + if success: + self.logger.info( + f"Successfully assigned {len(repo_names)} repositories to " + f"cost center '{cost_center_name}'" + ) + return len(repo_names) + else: + self.logger.error( + f"Failed to assign repositories to cost center '{cost_center_name}'" + ) + return 0 + + except Exception as e: + self.logger.error( + f"Error assigning repositories to cost center '{cost_center_name}': {str(e)}" + ) + return 0 + + def _create_budgets_for_cost_center(self, cost_center_id: str, cost_center_name: str): + """Create budgets for a cost center based on configuration. + + Args: + cost_center_id: UUID of the cost center + cost_center_name: Name of the cost center + """ + if not hasattr(self.config, 'budget_products'): + self.logger.warning("No budget products configured") + return + + self.logger.info(f"Creating budgets for cost center: {cost_center_name}") + + for product_name, product_config in self.config.budget_products.items(): + if not product_config.get('enabled', False): + self.logger.debug(f"Skipping {product_name} budget (disabled)") + continue + + amount = product_config.get('amount', 100) + + try: + if product_name == 'copilot': + # Use the original method for Copilot + success = self.github_api.create_cost_center_budget( + cost_center_id, cost_center_name, budget_amount=amount + ) + else: + # Use the new product budget method for other products + success = self.github_api.create_product_budget( + cost_center_id, cost_center_name, product_name, amount + ) + + if success: + self.logger.info(f"✅ Created ${amount} {product_name} budget for {cost_center_name}") + else: + self.logger.warning(f"❌ Failed to create {product_name} budget for {cost_center_name}") + + except Exception as e: + self.logger.error(f"Error creating {product_name} budget for {cost_center_name}: {str(e)}")