diff --git a/ORPHANED_USERS_FEATURE.md b/ORPHANED_USERS_FEATURE.md new file mode 100644 index 0000000..9709534 --- /dev/null +++ b/ORPHANED_USERS_FEATURE.md @@ -0,0 +1,314 @@ +# Orphaned User Detection and Removal Feature + +## Overview + +This feature automatically detects and optionally removes "orphaned users" from cost centers. Orphaned users are those who are assigned to a cost center but are no longer members of the corresponding GitHub team. + +## Why This Feature? + +When teams change over time (members leave, are removed, or switch teams), their cost center assignments can become stale. This feature keeps cost centers synchronized with actual team membership by: + +1. **Detecting** users in cost centers who are no longer in the corresponding team +2. **Reporting** these orphaned users with warnings +3. **Optionally removing** them based on configuration + +## How It Works + +### Detection Logic + +For each cost center being managed: +1. Fetch the **expected members** from the GitHub team +2. Fetch the **current members** from the cost center API +3. Calculate orphaned users: `current_members - expected_members` +4. Log warnings for any orphaned users found + +### Removal Logic (when enabled) + +If `teams.remove_orphaned_users: true`: +- Automatically remove orphaned users from their cost centers +- Log success/failure for each removal +- Provide summary statistics + +## Configuration + +### Enable/Disable + +Add to `config/config.yaml`: + +```yaml +teams: + enabled: true + scope: "enterprise" # or "organization" + mode: "auto" + + # Orphaned user handling + remove_orphaned_users: false # Set to true to enable automatic removal +``` + +### Default Behavior + +- **Default**: `false` (disabled) +- **When disabled**: Orphaned users are detected and logged but NOT removed +- **When enabled**: Orphaned users are detected and automatically removed + +## Usage Examples + +### Plan Mode (Preview) + +```bash +# See what would happen (with removal disabled) +python main.py --teams-mode --assign-cost-centers --mode plan + +# Output shows: +# MODE=plan: Orphaned user detection is DISABLED +# Users in cost centers but not in teams will remain assigned +``` + +```bash +# With removal enabled in config +python main.py --teams-mode --assign-cost-centers --mode plan + +# Output shows: +# MODE=plan: Orphaned user detection is ENABLED +# In apply mode, users in cost centers but not in teams will be removed +``` + +### Apply Mode (Execution) + +```bash +# Apply with removal disabled (default) +python main.py --teams-mode --assign-cost-centers --mode apply --yes + +# Adds users to cost centers but leaves orphaned users alone +``` + +```bash +# Apply with removal enabled +python main.py --teams-mode --assign-cost-centers --mode apply --yes + +# Output includes: +# [INFO] Checking for orphaned users... +# [WARNING] ⚠️ Found 3 orphaned users in cost center 'Team: Frontend' +# [WARNING] ⚠️ alice is in cost center but not in team +# [WARNING] ⚠️ bob is in cost center but not in team +# [INFO] Removing 3 orphaned users from 'Team: Frontend'... +# [INFO] ✅ Successfully removed 3 users from cost center +# [INFO] 📊 Orphaned users summary: Found 3 orphaned users, successfully removed 3 +``` + +## API Methods Added + +### 1. `get_cost_center_members(cost_center_id)` + +**Purpose**: Fetch current members of a cost center + +**Endpoint**: `GET /enterprises/{enterprise}/settings/billing/cost-centers/{cost_center_id}` + +**Returns**: List of usernames currently assigned to the cost center + +**Example**: +```python +members = github_manager.get_cost_center_members("abc-123-def") +# Returns: ['alice', 'bob', 'charlie'] +``` + +### 2. `remove_users_from_cost_center(cost_center_id, usernames)` + +**Purpose**: Remove multiple users from a cost center + +**Endpoint**: `DELETE /enterprises/{enterprise}/settings/billing/cost-centers/{cost_center_id}/resource` + +**Parameters**: +- `cost_center_id`: ID of the cost center +- `usernames`: List of usernames to remove + +**Returns**: Dict mapping username → success status (True/False) + +**Example**: +```python +results = github_manager.remove_users_from_cost_center( + "abc-123-def", + ["alice", "bob"] +) +# Returns: {'alice': True, 'bob': True} +``` + +## Implementation Details + +### Files Modified + +1. **`src/github_api.py`** + - Added `get_cost_center_members()` method + - Added `remove_users_from_cost_center()` method + +2. **`src/config_manager.py`** + - Added `teams_remove_orphaned_users` configuration property + +3. **`src/teams_cost_center_manager.py`** + - Added `_remove_orphaned_users()` private method + - Modified `sync_team_assignments()` to call orphaned user detection + - Added orphaned user handling in apply mode + +4. **`config/config.yaml` & `config/config.example.yaml`** + - Added `remove_orphaned_users` configuration option with documentation + +5. **`main.py`** + - Display `remove_orphaned_users` status in configuration output + +### Logging + +The feature provides comprehensive logging: + +- **INFO**: General operation status +- **WARNING**: Orphaned users detected +- **ERROR**: API failures or removal failures +- **DEBUG**: Detailed member counts + +### Error Handling + +- API failures are logged but don't stop execution +- Individual user removal failures are tracked separately +- Summary statistics show success/failure counts + +## Use Cases + +### Use Case 1: Team Restructuring + +**Scenario**: Your engineering team is split into "Frontend" and "Backend" teams. Some engineers move from one team to another. + +**Without this feature**: +- Users remain in their old cost center +- Cost reporting is inaccurate +- Manual cleanup required + +**With this feature**: +```yaml +teams: + remove_orphaned_users: true +``` +- Users automatically removed from old cost center +- Added to new cost center +- Cost reporting stays accurate + +### Use Case 2: Employee Departures + +**Scenario**: Team members leave the company and are removed from GitHub teams. + +**Without this feature**: +- Departed users remain in cost centers +- Inflated cost center counts +- Security/audit concerns + +**With this feature**: +- Departed users automatically removed from cost centers +- Accurate headcount per cost center +- Clean audit trail + +### Use Case 3: Temporary Team Assignments + +**Scenario**: Users temporarily join teams for projects, then return to their main team. + +**With this feature enabled**: +- Users are automatically moved back when they leave the temporary team +- No manual cleanup needed + +## Best Practices + +### 1. Test in Plan Mode First + +Always run with `--mode plan` to see what changes would be made: + +```bash +python main.py --teams-mode --assign-cost-centers --mode plan +``` + +### 2. Enable Gradually + +Start with `remove_orphaned_users: false` to see how many orphaned users exist: + +```bash +# Step 1: Run once to see orphaned users (they'll be logged as warnings) +python main.py --teams-mode --assign-cost-centers --mode apply --yes + +# Step 2: Review logs for orphaned users +# Step 3: If comfortable, enable removal +# Step 4: Run again with removal enabled +``` + +### 3. Regular Sync Schedule + +Run teams sync regularly to keep cost centers up-to-date: + +```bash +# Daily cron job +0 2 * * * cd /path/to/repo && python main.py --teams-mode --assign-cost-centers --mode apply --yes +``` + +### 4. Monitor Logs + +Review execution logs for: +- Number of orphaned users found +- Removal success rates +- Any API errors + +### 5. Consider Impact + +Before enabling `remove_orphaned_users: true`, consider: +- Do you have users manually added to cost centers outside of team membership? +- Are there legitimate reasons for users to be in cost centers but not teams? +- Do you have adequate logging/monitoring? + +## Limitations + +1. **Plan Mode**: Cannot show specific orphaned users in plan mode because cost centers may not exist yet. Orphaned user detection only runs in apply mode after cost centers are created/resolved. + +2. **Manual Assignments**: If you manually add users to cost centers outside of this tool, they will be detected as orphaned and removed if they're not in the corresponding team. + +3. **API Rate Limits**: Checking cost center membership adds API calls. Large numbers of cost centers may hit rate limits. + +4. **Single Cost Center**: Remember, users can only belong to ONE cost center at a time (GitHub API constraint). + +## Troubleshooting + +### Orphaned users not being removed + +**Check**: +1. Is `remove_orphaned_users: true` in config? +2. Running in `--mode apply` (not plan)? +3. Check logs for API errors + +### False positives (users incorrectly identified as orphaned) + +**Cause**: User is not in the team being synced + +**Solutions**: +- Verify team membership in GitHub +- Check that correct teams are configured +- Review team mappings in manual mode + +### API 404 errors when checking cost center members + +**Cause**: Cost center doesn't exist yet (plan mode issue) + +**Solution**: This is expected in plan mode. Orphaned detection only runs in apply mode. + +## Future Enhancements + +Potential improvements: +1. **Dry-run for orphaned users**: Show what would be removed without actually removing +2. **Whitelist**: Configure specific users to never be removed +3. **Notification**: Send alerts when orphaned users are found/removed +4. **Audit log export**: Export orphaned user reports to CSV + +## Summary + +The orphaned user detection and removal feature helps maintain clean, accurate cost center assignments by: + +- ✅ **Detecting** users who shouldn't be in cost centers +- ✅ **Reporting** discrepancies with clear warnings +- ✅ **Removing** orphaned users automatically (when enabled) +- ✅ **Working** with both organization and enterprise team scopes +- ✅ **Configurable** - enable/disable as needed +- ✅ **Safe** - disabled by default, clear logging, error handling + +This keeps your cost center data synchronized with actual team membership over time with minimal manual intervention. diff --git a/README.md b/README.md index dc376e7..cbbc055 100644 --- a/README.md +++ b/README.md @@ -1,444 +1,346 @@ -# GitHub cost center automation +# GitHub Copilot Cost Center Automation -This template repository provides scaffolding for cost center automation. In its initial version, the utility allows for auto-assignment of all Copilot-licensed users in a given enterprise to a cost center. Additional automation use cases will be added. +Automate GitHub Copilot license cost center assignments for your enterprise with two powerful modes: -## 🚀 Quick start guide +- **PRU-Based Mode**: Simple two-tier model (PRU overages allowed/not allowed) +- **Teams-Based Mode**: Automatic assignment based on GitHub team membership -### Option A: GitHub Actions (Recommended) +## 🚀 Quick Start (5 minutes) -1. **Create a new repository from this template** in your GitHub Enterprise organization: - - Click the green `Use this template` button at the top of this repository. - - Fill in the repository details and create your own copy. +### 1. Create Your Token -2. **Add your GitHub token as a repository secret**: - - Go to your new repository's **Settings** → **Secrets and variables** → **Actions** - - Add secret: `COST_CENTER_AUTOMATION_TOKEN` (your GitHub Personal Access Token with `manage_billing:enterprise` scope) +Create a GitHub Personal Access Token with these scopes: +- `manage_billing:enterprise` (required) +- `read:org` (required for Teams Mode) -3. **Run the workflow**: - - Go to the **Actions** tab → "Cost center automation" - - Click "Run workflow" → Select "incremental" mode → Run +[→ Create token here](https://github.com/settings/tokens/new) -4. **Done!** The workflow automatically: - - Detects your enterprise - - Creates cost centers if needed ("00 - No PRU overages", "01 - PRU overages allowed") - - Assigns all Copilot users to appropriate cost centers - - Runs every 6 hours automatically +### 2. Choose Your Mode -### Option B: Local Execution +
+PRU-Based Mode (Simple two-tier model) -1. **Clone and setup**: - ```bash - git clone - cd populate_cost_centers - pip install -r requirements.txt - ``` +```bash +# Clone and setup +git clone +cd cost-center-automation +pip install -r requirements.txt -2. **Configure**: - ```bash - cp config/config.example.yaml config/config.yaml - echo "GITHUB_TOKEN=your_github_token_here" > .env - # Edit config/config.yaml - set your enterprise name (or leave auto-detection) - ``` +# Configure +export GITHUB_TOKEN="your_token_here" +export GITHUB_ENTERPRISE="your-enterprise" -3. **Run**: - ```bash - # Create cost centers and assign users (with confirmation) - python main.py --create-cost-centers --assign-cost-centers --mode apply - ``` +# Run (creates cost centers automatically) +python main.py --create-cost-centers --assign-cost-centers --mode apply --yes +``` -That's it! Your Copilot users are now organized in cost centers for better billing tracking. +**Done!** All users are now in "00 - No PRU overages" cost center. -## Overview +To allow specific users PRU overages, edit `config/config.yaml`: +```yaml +cost_centers: + prus_exception_users: + - "alice" + - "bob" +``` -Automates GitHub Copilot license cost center assignments for enterprises using a simple two-tier model: -- **Default**: All Copilot users are added to `00 - No PRU overages` cost center -- **Exceptions**: Specified users are added to `01 - PRU overages allowed` cost center +
-Supports both interactive execution and automated scheduling with incremental processing. +
+Teams-Based Mode (Sync with GitHub teams) -## Features +```bash +# Clone and setup +git clone +cd cost-center-automation +pip install -r requirements.txt + +# Configure +cp config/config.example.yaml config/config.yaml +export GITHUB_TOKEN="your_token_here" +export GITHUB_ENTERPRISE="your-enterprise" + +# Edit config/config.yaml +teams: + enabled: true + scope: "organization" # or "enterprise" + organizations: + - "your-org-name" + +# Run +python main.py --teams-mode --assign-cost-centers --mode apply --yes +``` -- **Automatic cost center creation**: Creates cost centers automatically (or use existing cost centers, if preferred) -- **Incremental processing**: Only process users added since last run (perfect for cron jobs) -- **Enhanced result logging**: Real-time success/failure tracking with user-level detail -- Plan vs apply execution (`--mode plan|apply`) + interactive safety prompt (bypass with `--yes`) -- Container friendly (Dockerfile + docker-compose) -- GitHub Actions & cron automation examples +**Done!** Cost centers created for each team, users automatically assigned. -## Prerequisites +See [TEAMS_QUICKSTART.md](TEAMS_QUICKSTART.md) for more details. + +
+ +### 3. Automate (Optional) -- GitHub Enterprise Cloud admin access -- GitHub Personal Access Token with `manage_billing:enterprise` scope +Set up GitHub Actions for automatic syncing every 6 hours - see [Automation](#automation) below. + +## Features -**Additional requirements for local execution:** -- Python 3.8 or higher +### Two Operational Modes -## Installation +**PRU-Based Mode** (Default) +- Simple two-tier model: PRU overages allowed/not allowed +- Automatic cost center creation with default names +- Exception list for users who need PRU access +- Incremental processing support (only new users) -1. Clone or download your repository created from this template -2. Install required dependencies: - ```bash - pip install -r requirements.txt - ``` +**Teams-Based Mode** +- Organization scope: Sync teams from specific GitHub orgs +- Enterprise scope: Sync all teams across the enterprise +- Automatic cost center creation with bracket notation naming +- Orphaned user detection and removal +- Single assignment (multi-team users get last team's assignment) -3. Copy the example configuration: - ```bash - cp config/config.example.yaml config/config.yaml - ``` +### Additional Features +- 🔄 **Plan/Apply execution**: Preview changes before applying +- 📊 **Enhanced logging**: Real-time success/failure tracking +- 🐳 **Container ready**: Dockerfile and docker-compose included +- ⚙️ **Automation examples**: GitHub Actions, cron, and shell scripts +- 🔧 **Auto-creation**: Automatic cost center creation (no manual UI setup) -4. Set up your GitHub token: - ```bash - echo "GITHUB_TOKEN=your_actual_token_here" > .env - ``` +## Prerequisites -5. Edit `config/config.yaml` with your: - - GitHub Enterprise name - - Cost center IDs (if they exist) OR enable auto-creation - - Any users which should get access to additional PRUs +- GitHub Enterprise Cloud with admin access +- GitHub Personal Access Token with scopes: + - `manage_billing:enterprise` (required for all modes) + - `read:org` (required for Teams Mode) +- Python 3.8+ (for local execution) ## Configuration -All configuration lives in: `config/config.yaml` (example below) +Configuration lives in `config/config.yaml` (copy from `config/config.example.yaml`). + +### PRU-Based Mode Configuration -### Core Keys ```yaml github: - # GitHub Enterprise name (required) - enterprise: "your_enterprise_name" + enterprise: "" # Or set via GITHUB_ENTERPRISE env var cost_centers: - # Manual cost center IDs (only needed when auto_create is false) - no_prus_cost_center_id: "REPLACE_WITH_NO_PRUS_COST_CENTER_ID" - prus_allowed_cost_center_id: "REPLACE_WITH_PRUS_ALLOWED_COST_CENTER_ID" + auto_create: true # Automatically create cost centers + no_prus_cost_center_name: "00 - No PRU overages" + prus_allowed_cost_center_name: "01 - PRU overages allowed" - # Users who should get access to PRU overages (always required) + # Users who need PRU access prus_exception_users: - # - "alice" - # - "bob" + - "alice" + - "bob" +``` + +### Teams-Based Mode Configuration + +```yaml +teams: + enabled: true + scope: "organization" # or "enterprise" + mode: "auto" # One cost center per team - # Auto-creation settings (creates cost centers if they don't exist) - auto_create: false # Set to true to enable auto-creation + organizations: # Only for organization scope + - "your-org" - # Cost center names (only used when auto_create is true) - no_prus_cost_center_name: "00 - No PRU overages" # Name for no-PRU cost center - prus_allowed_cost_center_name: "01 - PRU overages allowed" # Name for PRU-allowed cost center - -logging: - level: "INFO" - file: "logs/populate_cost_centers.log" + auto_create_cost_centers: true + remove_orphaned_users: true ``` -### Placeholder Warnings -If either cost center ID still equals `REPLACE_WITH_*` (or the sample defaults) a WARNING is logged. In plan mode this is informational; in apply mode you should fix values before proceeding. +**Cost Center Naming:** +- Organization scope: `[org team] {org-name}/{team-name}` +- Enterprise scope: `[enterprise team] {team-name}` -### User Assignment Logic -- Default: everyone → `no_prus_cost_center_id` -- If username in `prus_exception_users` → `prus_allowed_cost_center_id` +### Environment Variables -### Environment Variables (override config) -- `GITHUB_TOKEN` -- `GITHUB_ENTERPRISE` +Set these instead of config file values: +- `GITHUB_TOKEN` (required) +- `GITHUB_ENTERPRISE` (required) -### Duplicate Seat Handling -If the Copilot seat API returns the same user more than once, duplicates are skipped and summarized in a warning. +## Teams Mode Details -## Cost Center Auto-Creation +For complete Teams Mode documentation, see: +- [TEAMS_QUICKSTART.md](TEAMS_QUICKSTART.md) - Step-by-step setup guide +- [TEAMS_INTEGRATION.md](TEAMS_INTEGRATION.md) - Full reference documentation -This tool can automatically create cost centers if they don't exist, eliminating manual GitHub UI setup: +### Key Concepts -### Quick Start with Auto-Creation +**Organization vs Enterprise Scope** +- **Organization**: Syncs teams from specific GitHub organizations you specify +- **Enterprise**: Syncs all teams across your entire GitHub Enterprise -```bash -# Plan what cost centers would be created -python main.py --create-cost-centers --show-config +**Cost Center Naming** +- Organization scope: `[org team] {org-name}/{team-name}` +- Enterprise scope: `[enterprise team] {team-name}` -# Create cost centers and assign users (with confirmation prompt) -python main.py --create-cost-centers --assign-cost-centers --mode apply - -# Non-interactive creation for automation -python main.py --create-cost-centers --assign-cost-centers --mode apply --yes -``` +**Multi-Team Users** +- Each user can only belong to ONE cost center +- Multi-team users are assigned to their last team's cost center +- Warnings logged for review before applying -### Default Cost Center Names -- **`"00 - No PRU overages"`** - For users without PRU access (majority) -- **`"01 - PRU overages allowed"`** - For exception users with PRU access +### Manual Mode -### Configuration Options +For advanced use cases, map specific teams to specific cost centers: -**Enable via config file:** ```yaml -cost_centers: - auto_create: true # Enable automatic creation - no_prus_cost_center_name: "Custom No PRU Name" # Optional: customize names - prus_allowed_cost_center_name: "Custom PRU Name" +teams: + mode: "manual" + team_mappings: + "my-org/frontend": "Engineering: Frontend" + "my-org/backend": "Engineering: Backend" ``` -**Or use command line flag:** `--create-cost-centers` - -### How It Works -1. **Detection**: Checks if cost centers with specified names already exist -2. **Creation**: Creates missing cost centers via GitHub Enterprise API -3. **Assignment**: Uses the created cost center IDs for user assignments -4. **Idempotent**: Safe to run multiple times - won't create duplicates - ## Usage -### Basic Usage +### Common Commands ```bash -# Show current configuration and PRUs exception users +# View configuration python main.py --show-config +python main.py --teams-mode --show-config -# List all Copilot license holders (shows PRUs exceptions) +# List all Copilot users python main.py --list-users - -# Plan cost center assignments (no changes made) -python main.py --assign-cost-centers --mode plan - -# Apply cost center assignments (will prompt for confirmation) -python main.py --assign-cost-centers --mode apply ``` -### Additional Examples +### PRU-Based Mode ```bash -# Apply without interactive confirmation (for automation) -python main.py --assign-cost-centers --mode apply --yes - -# Generate summary report (plan mode by default) -python main.py --assign-cost-centers --summary-report - -# Process only specific users (plan) -python main.py --users user1,user2,user3 --assign-cost-centers --mode plan +# Plan assignments (preview, no changes) +python main.py --assign-cost-centers --mode plan -# Auto-create cost centers and assign users (with confirmation) -python main.py --create-cost-centers --assign-cost-centers --mode apply +# Apply assignments (with confirmation) +python main.py --assign-cost-centers --mode apply -# Auto-create and assign (non-interactive) +# Apply without confirmation (automation) python main.py --create-cost-centers --assign-cost-centers --mode apply --yes -# Incremental processing - only process users added since last run (ideal for cron jobs) +# Incremental mode (only new users, for cron jobs) python main.py --assign-cost-centers --incremental --mode apply --yes +``` -# Plan mode with incremental processing (see what new users would be processed) -python main.py --assign-cost-centers --incremental --mode plan +### Teams-Based Mode -# Full cron job setup: incremental processing with detailed logging and reports -python main.py --assign-cost-centers --incremental --mode apply --yes --summary-report -``` +```bash +# Plan assignments (preview, no changes) +python main.py --teams-mode --assign-cost-centers --mode plan -## Incremental Processing +# Apply assignments (with confirmation) +python main.py --teams-mode --assign-cost-centers --mode apply -For efficient cron job automation, the `--incremental` flag processes only users added since the last successful run: +# Apply without confirmation (automation) +python main.py --teams-mode --assign-cost-centers --mode apply --yes -### How it Works +# Generate summary report +python main.py --teams-mode --summary-report +``` -1. **First Run**: Processes all users and saves timestamp to `exports/.last_run_timestamp` -2. **Subsequent Runs**: Only processes users with `created_at` timestamp after the last run -3. **No New Users**: Exits quickly with "No new users found since last run" -4. **Timestamp Updates**: Only saved on successful `--mode apply` executions +**Note:** Incremental mode is NOT supported in Teams Mode. All team members are processed every run. -### Automation Script +## Incremental Processing (PRU Mode Only) -The included automation script defaults to incremental mode: +Process only users added since the last run - perfect for cron jobs: ```bash -# Incremental mode (default - recommended for cron jobs) -./automation/update_cost_centers.sh +# First run: processes all users, saves timestamp +python main.py --assign-cost-centers --incremental --mode apply --yes -# Full mode (processes all users) -./automation/update_cost_centers.sh full +# Subsequent runs: only new users +python main.py --assign-cost-centers --incremental --mode apply --yes ``` -## Enhanced Result Logging - -The tool provides **real-time detailed logging** showing actual assignment results: - -### What You Get +**Note:** Teams Mode does not support incremental processing. -- **✅ Individual User Success**: `✅ username → cost_center_id` -- **❌ Individual User Failures**: `❌ username → cost_center_id (API Error)` -- **📊 Batch Progress**: `Batch 1 completed: 5 successful, 0 failed` -- **📈 Final Results**: `📊 ASSIGNMENT RESULTS: 95/100 users successfully assigned` -- **🎯 Success Summary**: `✅ Assignment success rate: 95/100 users` +## Logging -### Example Output +Logs are written to `logs/populate_cost_centers.log` with detailed tracking: ```log -2025-09-24 10:39:06 [INFO] src.github_api: ✅ Successfully assigned 3 users to cost center abc123 -2025-09-24 10:39:06 [INFO] src.github_api: ✅ user1 → abc123 -2025-09-24 10:39:06 [INFO] src.github_api: ✅ user2 → abc123 -2025-09-24 10:39:06 [INFO] src.github_api: ✅ user3 → abc123 -2025-09-24 10:39:06 [INFO] src.github_api: 📊 ASSIGNMENT RESULTS: 3/3 users successfully assigned -2025-09-24 10:39:06 [INFO] src.github_api: 🎉 All users successfully assigned! +2025-10-08 10:39:06 [INFO] ✅ Successfully added 3 users to cost center abc123 +2025-10-08 10:39:06 [INFO] ✅ user1 → abc123 +2025-10-08 10:39:06 [INFO] ✅ user2 → abc123 +2025-10-08 10:39:06 [INFO] 📊 ASSIGNMENT RESULTS: 3/3 users successfully assigned ``` -## Output Files - -Generated files include timestamp for traceability: - -- `logs/populate_cost_centers.log` – Detailed execution log with enhanced result tracking -- `exports/.last_run_timestamp` – Timestamp for incremental processing (JSON format) - -### Log File Features +## Automation -- **Rotating logs** to prevent disk space issues -- **Structured format** with timestamps and log levels -- **Enhanced result tracking** with individual user success/failure details -- **API response logging** for troubleshooting -- **Performance metrics** and execution summaries +### GitHub Actions (Recommended) -## Configuration Files +The included workflow automatically syncs cost centers every 6 hours: -- `config/config.yaml` - Single configuration file containing all settings (GitHub API, cost centers, logging) -- `config/config.example.yaml` - Example template to copy from -- `.env` - Environment variables (GitHub token, optional overrides) +1. Add token as secret: `COST_CENTER_AUTOMATION_TOKEN` +2. Go to **Actions** tab → "Cost center automation" +3. Click "Run workflow" → Select mode → Run -## Automation +See `.github/workflows/` for configuration. ### Docker + ```bash -# Build image +# Build and run docker build -t copilot-cc . - -# Plan mode docker run --rm -e GITHUB_TOKEN=$GITHUB_TOKEN copilot-cc \ - python main.py --assign-cost-centers --mode plan --summary-report - -# Apply with auto-creation -docker run --rm -e GITHUB_TOKEN=$GITHUB_TOKEN copilot-cc \ - python main.py --create-cost-centers --assign-cost-centers --mode apply --yes --verbose + python main.py --assign-cost-centers --mode apply --yes # Background service docker compose up -d --build ``` -### GitHub Actions -```yaml -# Incremental processing (recommended for scheduled workflows) -- name: Apply cost centers (incremental) - run: | - python main.py --assign-cost-centers --incremental --mode apply --yes --summary-report - -# Full processing (weekly/monthly) -- name: Apply cost centers (full) - run: | - python main.py --assign-cost-centers --mode apply --yes --summary-report - -# Plan mode for validation -- name: Plan cost center assignments - run: | - python main.py --assign-cost-centers --incremental --mode plan --summary-report -``` - -### Cron / Shell Script -See `automation/update_cost_centers.sh` - uses incremental processing by default for efficient cron execution: - -```bash -# Incremental mode (default - processes only new users) -./automation/update_cost_centers.sh - -# Full mode (processes all users) -./automation/update_cost_centers.sh full -``` - -The script includes detailed logging and `--summary-report` for comprehensive automation monitoring. +### Cron Jobs -**Monitor execution:** ```bash -# View live logs -tail -f logs/populate_cost_centers.log +# PRU mode with incremental processing (hourly) +0 * * * * cd /path/to/repo && ./automation/update_cost_centers.sh -# Cron job examples -0 * * * * cd /path/to/populate_cost_centers && ./automation/update_cost_centers.sh >/dev/null 2>&1 # Hourly incremental -0 2 * * 0 cd /path/to/populate_cost_centers && ./automation/update_cost_centers.sh full >/dev/null 2>&1 # Weekly full +# Teams mode (weekly) +0 2 * * 1 cd /path/to/repo && python main.py --teams-mode --assign-cost-centers --mode apply --yes ``` -## Keeping up-to-date - -This template includes a built-in **template sync workflow** that automatically keeps your repository updated with the latest improvements and fixes from the upstream template. - -### How it works - -1. **Automatic Updates**: Every Monday at 06:00 UTC, the workflow checks for template updates -2. **Smart Merging**: Only pulls changes that don't conflict with your customizations -3. **Protected Files**: Your configuration and custom files are never overwritten -4. **Pull Request**: Creates a PR with changes for your review before applying - -### Setup (One-time) - -The template sync workflow is **automatically configured** and ready to use! - -**Required setup** (for most users): -1. **Create a GitHub Personal Access Token**: - - Go to GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens - - Create token with these permissions: - - **Repository access**: Your consumer repository - - **Permissions**: `Contents: Write`, `Pull requests: Write` - - **Public repositories**: `Contents: Read` (to read the template) - -2. **Add the token as a repository secret**: - - Go to your repository's **Settings** → **Secrets and variables** → **Actions** - - Add secret: `TEMPLATE_SYNC_TOKEN` = your PAT - -**That's it!** The workflow will automatically sync from `github/cost-center-automation` every Monday. +See `automation/update_cost_centers.sh` for the included automation script. -### What gets updated +## Keeping Up-to-Date -✅ **Always synced:** -- Workflow improvements (`/.github/workflows/`) -- Bug fixes and new features in source code -- Documentation updates -- Dependency updates +This repository includes automatic template sync from `github/cost-center-automation`. -🔒 **Never overwritten:** -- `config/config.yaml` (your settings) -- `.github/renovate.json` (custom Renovate config) -- Any files listed in `.syncignore` +**Setup:** +1. Create a PAT with `Contents: Write` and `Pull requests: Write` +2. Add as secret: `TEMPLATE_SYNC_TOKEN` +3. Automatic sync runs every Monday, creating PRs with updates -### Manual sync +**What's synced:** Code, workflows, docs, dependencies +**What's protected:** `config/config.yaml`, `.syncignore` files -Trigger an immediate sync check: -- Go to **Actions** → "Sync from template" → "Run workflow" - -### Customize sync behavior - -Edit `.syncignore` to protect additional files from being overwritten: - -```bash -# Add patterns for files you want to keep unchanged -echo "my-custom-script.sh" >> .syncignore -``` - -### Disable auto-sync - -If you prefer manual updates only: -- Delete `.github/workflows/template-sync.yml` -- Or edit the workflow and remove the `schedule:` trigger +Manual trigger: **Actions** → "Sync from template" → "Run workflow" ## Troubleshooting -| Issue | Explanation | Action | -|-------|-------------|--------| -| Placeholder warning | Cost center ID not replaced | Edit `config/config.yaml` | -| 401 / 403 errors | Token missing scope / expired | Regenerate PAT with required scopes | -| No users returned | No active Copilot seats | Verify seat assignments in Enterprise settings | -| Apply aborted | Confirmation not granted | Re-run with `--yes` or type `apply` at prompt | -| Cost center creation failed | Missing enterprise permissions | Ensure token has `manage_billing:enterprise` scope | +| Issue | Solution | +|-------|----------| +| 401/403 errors | Regenerate token with correct scopes | +| No teams found | Verify `read:org` scope for Teams Mode | +| Cost center creation fails | Ensure `manage_billing:enterprise` scope | +| Multi-team user warnings | Review plan output, adjust team structure if needed | -Logs: inspect `logs/populate_cost_centers.log` for detailed traces (DEBUG if `--verbose`). +Check `logs/populate_cost_centers.log` for detailed traces. Use `--verbose` for DEBUG logging. ## Contributing -1. Create your own repository from the template & branch (`feat/`) -2. Add/adjust tests (future enhancement: test harness TBD) -3. Keep changeset focused & documented in commit message -4. Submit PR with before/after summary -5. Tag reviewers & link related issues +1. Fork this repository and create a branch (`feat/`) +2. Make focused changes with clear commit messages +3. Submit PR with description and link related issues + +## Additional Documentation + +- [TEAMS_QUICKSTART.md](TEAMS_QUICKSTART.md) - Teams Mode setup guide +- [TEAMS_INTEGRATION.md](TEAMS_INTEGRATION.md) - Teams Mode reference +- [ORPHANED_USERS_FEATURE.md](ORPHANED_USERS_FEATURE.md) - Orphaned users documentation ## License -This project is licensed under the terms of the MIT open source license. Please refer to the [LICENSE](LICENSE) file for the full terms. +This project is licensed under the MIT License. See [LICENSE](LICENSE) file for details. --- -Maintained state: Tags `v0.1.0` (baseline refactor), `v0.1.1` (apply confirmation & flags). Latest: Enhanced result logging, incremental processing, automatic cost center creation. + +**Latest Features:** Teams-based assignment (organization & enterprise scope), orphaned user detection, bracket notation naming, enhanced logging, incremental processing diff --git a/TEAMS_INTEGRATION.md b/TEAMS_INTEGRATION.md new file mode 100644 index 0000000..4014757 --- /dev/null +++ b/TEAMS_INTEGRATION.md @@ -0,0 +1,261 @@ +# Teams Integration - Quick Reference + +This document provides a quick reference for using the new Teams Integration feature in the cost center automation utility. + +## Overview + +The Teams Integration mode allows you to automatically assign GitHub Copilot users to cost centers based on their GitHub team membership. This is an **alternative mode** to the PRU-based assignment logic. + +## Key Concepts + +### Two Modes of Operation + +1. **Auto Mode**: Automatically creates one cost center per team + - Best for: Organizations wanting 1:1 mapping of teams to cost centers + - Naming: Uses customizable template (default: "Team: {team_name}") + +2. **Manual Mode**: Explicitly map teams to cost centers + - Best for: Organizations wanting to group multiple teams or use existing cost centers + - Requires: Team mappings defined in configuration + +### Multi-Team Membership + +**Important:** Each user can only belong to ONE cost center. + +If a user belongs to multiple teams: +- They will be assigned to the cost center of the **last team processed** +- Previous cost center assignments are overwritten +- The system logs warnings for users with multiple team memberships +- You can review these warnings before applying to understand conflicts + +## Configuration Examples + +### Auto Mode Configuration + +```yaml +# config.yaml +teams: + enabled: true + mode: "auto" + + organizations: + - "my-org" + - "another-org" + + auto_create_cost_centers: true + cost_center_name_template: "Team: {team_name}" +``` + +### Manual Mode Configuration + +```yaml +# config.yaml +teams: + enabled: true + mode: "manual" + + organizations: + - "my-org" + + auto_create_cost_centers: true + + team_mappings: + "my-org/frontend": "Engineering: Frontend" + "my-org/backend": "Engineering: Backend" + "my-org/mobile": "Engineering: Mobile" + "my-org/devops": "CC-DEVOPS-001" # Use existing cost center ID +``` + +## Command Examples + +### View Configuration +```bash +# Show what teams mode would do +python main.py --teams-mode --show-config +``` + +### Plan Mode (Dry Run) +```bash +# See what assignments would be made (no changes) +python main.py --teams-mode --assign-cost-centers --mode plan +``` + +### Apply Mode (With Confirmation) +```bash +# Apply assignments (will prompt for confirmation) +python main.py --teams-mode --assign-cost-centers --mode apply +``` + +### Apply Mode (Non-Interactive) +```bash +# Apply without confirmation (for automation) +python main.py --teams-mode --assign-cost-centers --mode apply --yes +``` + +### Generate Summary Report +```bash +# Show summary of teams and cost centers +python main.py --teams-mode --summary-report +``` + +### Combined Operations +```bash +# Plan with summary report +python main.py --teams-mode --assign-cost-centers --summary-report --mode plan + +# Apply with verbose logging +python main.py --teams-mode --assign-cost-centers --mode apply --yes --verbose +``` + +## API Endpoints Used + +The Teams Integration mode uses these GitHub REST API endpoints: + +1. **List Organization Teams** + - Endpoint: `GET /orgs/{org}/teams` + - Scope: `read:org` + - Returns: List of all teams in the organization + +2. **List Team Members** + - Endpoint: `GET /orgs/{org}/teams/{team_slug}/members` + - Scope: `read:org` + - Returns: List of all members in the team + +3. **Create Cost Center** (if auto-create enabled) + - Endpoint: `POST /enterprises/{enterprise}/settings/billing/cost-centers` + - Scope: `manage_billing:enterprise` + - Creates new cost centers + +4. **Add Users to Cost Center** + - Endpoint: `POST /enterprises/{enterprise}/settings/billing/cost-centers/{id}/resource` + - Scope: `manage_billing:enterprise` + - Adds users to cost center (batch: up to 50 users) + +## Token Permissions + +Your GitHub Personal Access Token must have: +- `manage_billing:enterprise` - To manage cost centers +- `read:org` - To read team information + +## Behavior Notes + +### What Gets Processed +- **Auto Mode**: All teams in configured organizations +- **Manual Mode**: Only teams with explicit mappings + +### What Gets Skipped +- Teams with no members (logged as info) +- Teams without mappings in manual mode (logged as warning) +- Users not in any configured team (not encountered since input is teams) + +### Multi-Team User Behavior +- Users in multiple teams are logged with a WARNING +- The last team processed determines the final cost center assignment +- Previous assignments are overwritten (users can only be in ONE cost center) + +### Multi-Organization Support +- Process teams from multiple organizations in a single run +- Cost center names can include organization name using `{org}` variable +- Example template: `"{org} - {team_name}"` → "acme-corp - Frontend" + +## Comparison: Teams Mode vs PRU Mode + +| Aspect | Teams Mode | PRU Mode | +|--------|-----------|----------| +| **Trigger** | `--teams-mode` flag or `teams.enabled: true` | Default mode | +| **Input** | GitHub teams | Copilot license holders | +| **Logic** | Team membership | Exception list | +| **Cost Centers** | One per team (auto) or mapped | Two (with/without PRU) | +| **Multi-assignment** | Yes (multi-team users) | No | +| **Config Complexity** | Medium-High | Low | +| **Use Case** | Team-based allocation | Simple usage tiers | + +## Automation Example + +### Cron Job - Daily Team Sync +```bash +# /etc/cron.d/copilot-team-sync +# Run daily at 2 AM to sync teams with cost centers +0 2 * * * cd /path/to/cost-center-automation && python main.py --teams-mode --assign-cost-centers --mode apply --yes >> /var/log/copilot-team-sync.log 2>&1 +``` + +### GitHub Actions - Weekly Team Sync +```yaml +# .github/workflows/team-sync.yml +name: Weekly Team Cost Center Sync + +on: + schedule: + - cron: '0 2 * * 1' # Every Monday at 2 AM + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Sync teams to cost centers + env: + GITHUB_TOKEN: ${{ secrets.COST_CENTER_TOKEN }} + run: | + python main.py --teams-mode --assign-cost-centers --mode apply --yes --summary-report +``` + +## Troubleshooting + +### "Teams mode requires organizations to be configured" +**Solution**: Add organizations to `config.yaml`: +```yaml +teams: + organizations: + - "your-org-name" +``` + +### "No mapping found for team org/team-slug in manual mode" +**Solution**: Add team mapping in manual mode: +```yaml +teams: + mode: "manual" + team_mappings: + "org/team-slug": "Cost Center Name" +``` + +### "Failed to fetch teams for org X" +**Possible causes**: +- Token lacks `read:org` scope +- Organization name is incorrect +- Token doesn't have access to the organization + +### Warning: "User X is in multiple teams" +**This is expected**: The tool warns you when a user belongs to multiple teams. The user will be assigned to the LAST team's cost center processed. Review these warnings in plan mode to understand which cost center each multi-team user will be assigned to. + +## Best Practices + +1. **Start with Plan Mode**: Always run with `--mode plan` first to preview changes +2. **Review Multi-Team Warnings**: Check warning logs for users in multiple teams before applying +3. **Use Verbose Logging**: Add `--verbose` flag for detailed operation logs +4. **Test with Small Orgs First**: Test the configuration with a small organization before scaling +5. **Consider Team Structure**: Users should ideally belong to one primary team for cost allocation +6. **Use Manual Mode for Control**: If you have many multi-team users, use manual mode to control which teams are processed +7. **Automate Safely**: Use `--yes` flag only in fully automated environments +8. **Regular Sync**: Run team sync regularly (daily/weekly) to keep cost centers up-to-date + +**Note:** Incremental sync (processing only new/changed users) is NOT currently supported for teams mode. All team members are processed every run. + +## Future Enhancements + +When the cost center membership API becomes available: +- Option to check existing cost center membership before assignment +- Ability to remove users from cost centers (not currently supported) +- Diff mode to only add new team members (incremental sync) + +**Incremental sync for teams mode is not available yet.** diff --git a/TEAMS_QUICKSTART.md b/TEAMS_QUICKSTART.md new file mode 100644 index 0000000..b6fd8fc --- /dev/null +++ b/TEAMS_QUICKSTART.md @@ -0,0 +1,246 @@ +# Teams Integration - Quick Start Guide + +Get started with GitHub Teams Integration for cost center automation in 5 minutes! + +## Step 1: Verify Prerequisites + +Ensure you have: +- ✅ GitHub Enterprise Cloud admin access +- ✅ Personal Access Token with: + - `manage_billing:enterprise` scope + - `read:org` scope (NEW for teams mode) +- ✅ Python 3.8+ installed +- ✅ Repository cloned and dependencies installed + +```bash +cd cost-center-automation +pip install -r requirements.txt +``` + +## Step 2: Configure Organizations + +Edit `config/config.yaml` and add your organizations: + +```yaml +teams: + enabled: true + mode: "auto" # Start with auto mode + + # Add your organizations here + organizations: + - "your-github-org" + + auto_create_cost_centers: true + cost_center_name_template: "Team: {team_name}" +``` + +## Step 3: Set Environment Variables + +```bash +# Set your GitHub token and enterprise +export GITHUB_TOKEN="ghp_your_token_here" +export GITHUB_ENTERPRISE="your-enterprise-name" +``` + +Or create a `.env` file: +``` +GITHUB_TOKEN=ghp_your_token_here +GITHUB_ENTERPRISE=your-enterprise-name +``` + +## Step 4: Test with Plan Mode + +Run in plan mode to see what would happen (no changes made): + +```bash +python main.py --teams-mode --assign-cost-centers --mode plan +``` + +You should see: +- List of teams found in your organization +- Cost centers that would be created +- Users that would be assigned +- Summary of assignments + +## Step 5: Review the Plan + +Check the output for: +- ✅ All expected teams are listed +- ✅ Cost center names look correct +- ✅ Team member counts are accurate +- ⚠️ Review any warnings about users in multiple teams +- ✅ No unexpected errors + +Example output: +``` +2025-10-06 10:00:00 [INFO] Found 5 teams in your-github-org +2025-10-06 10:00:01 [INFO] Team Frontend (your-github-org/frontend) → Cost Center 'Team: Frontend': 12 members +2025-10-06 10:00:02 [INFO] Team Backend (your-github-org/backend) → Cost Center 'Team: Backend': 8 members +... +2025-10-06 10:00:04 [WARNING] ⚠️ Found 3 users who are members of multiple teams. +2025-10-06 10:00:04 [WARNING] ⚠️ alice is in multiple teams [org/frontend, org/mobile] → will be assigned to 'Team: Mobile' +2025-10-06 10:00:05 [INFO] Team assignment summary: 5 cost centers, 42 unique users (each assigned to exactly ONE cost center) +``` + +## Step 6: Apply Changes + +When you're ready to apply: + +```bash +# With confirmation prompt +python main.py --teams-mode --assign-cost-centers --mode apply + +# Or skip confirmation (for automation) +python main.py --teams-mode --assign-cost-centers --mode apply --yes +``` + +## Step 7: Verify Results + +Check your GitHub Enterprise cost centers: +1. Go to `https://github.com/enterprises/YOUR-ENTERPRISE/billing/cost_centers` +2. Verify new cost centers were created +3. Click into each cost center to see assigned users + +## Next Steps + +### Option A: Keep Auto Mode +If you're happy with one cost center per team, you're done! Just run regularly: + +```bash +# Daily sync (cron example) +0 2 * * * cd /path/to/repo && python main.py --teams-mode --assign-cost-centers --mode apply --yes +``` + +### Option B: Switch to Manual Mode +For more control over team-to-cost-center mappings: + +```yaml +teams: + enabled: true + mode: "manual" + + organizations: + - "your-github-org" + + team_mappings: + "your-github-org/frontend": "Engineering: Frontend" + "your-github-org/backend": "Engineering: Backend" + "your-github-org/mobile-ios": "Engineering: Mobile" + "your-github-org/mobile-android": "Engineering: Mobile" # Same cost center +``` + +### Option C: Use Custom Naming Template +Customize how cost centers are named in auto mode: + +```yaml +teams: + mode: "auto" + # Include org name in cost center + cost_center_name_template: "{org} - {team_name}" + # Or use team slug + cost_center_name_template: "Team: {team_slug}" + # Or get creative + cost_center_name_template: "[{org}] {team_name}" +``` + +## Common Scenarios + +### Scenario 1: Multiple Organizations +```yaml +teams: + organizations: + - "org1" + - "org2" + - "org3" + cost_center_name_template: "{org}: {team_name}" +``` + +### Scenario 2: Existing Cost Centers (Manual Mode) +```yaml +teams: + mode: "manual" + auto_create_cost_centers: false # Use existing IDs + team_mappings: + "my-org/team-a": "CC-001-EXISTING" + "my-org/team-b": "CC-002-EXISTING" +``` + +### Scenario 3: Mixed Approach +```yaml +teams: + mode: "manual" + auto_create_cost_centers: true # Create if needed + team_mappings: + "my-org/frontend": "CC-FRONTEND-001" # Existing + "my-org/backend": "Engineering: Backend" # Will be created +``` + +## Troubleshooting + +### Error: "Teams mode requires organizations to be configured" +**Fix**: Add organizations to config: +```yaml +teams: + organizations: + - "your-org" +``` + +### Error: "Failed to fetch teams for org X" +**Possible causes**: +- Token missing `read:org` scope → Regenerate token with correct scope +- Wrong org name → Check spelling +- No access to org → Verify token has access + +### Warning: "No mapping found for team org/team-slug in manual mode" +**Fix**: Add mapping for the team: +```yaml +teams: + team_mappings: + "org/team-slug": "Cost Center Name" +``` + +### Warning: User in multiple teams +**This is a conflict notification.** Users can only belong to ONE cost center. If a user is in multiple teams, they'll be assigned to the LAST team's cost center processed. Review the warning logs to see which cost center each multi-team user will get. + +**Solution:** If this is not desired: +1. Use manual mode to control which teams are processed +2. Ensure users have one primary team for cost allocation +3. Review team membership structure + +## Getting Help + +- 📖 Full documentation: `README.md` +- 🔍 Detailed reference: `TEAMS_INTEGRATION.md` +- 💡 Implementation details: `IMPLEMENTATION_SUMMARY.md` +- 🐛 Report issues: GitHub Issues + +## Quick Command Reference + + +```bash +# Show configuration +python main.py --teams-mode --show-config + +# Plan (dry run) +python main.py --teams-mode --assign-cost-centers --mode plan + +# Apply with confirmation +python main.py --teams-mode --assign-cost-centers --mode apply + +# Apply without confirmation +python main.py --teams-mode --assign-cost-centers --mode apply --yes + +# Generate summary report +python main.py --teams-mode --summary-report + +# Verbose mode (for debugging) +python main.py --teams-mode --assign-cost-centers --mode plan --verbose + +# Note: Incremental mode is NOT supported for teams mode. All team members are processed every run. +``` + +--- + +**🎉 That's it! You're now using GitHub Teams Integration for cost center automation.** + +For advanced usage, automation setup, and best practices, see the full documentation in `TEAMS_INTEGRATION.md`. diff --git a/config/config.example.yaml b/config/config.example.yaml index 11a7e3b..fd47b4f 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -24,4 +24,37 @@ cost_centers: # Cost center names (only used when auto_create is true) no_prus_cost_center_name: "00 - No PRU overages" # Name for no-PRU cost center - prus_allowed_cost_center_name: "01 - PRU overages allowed" # Name for PRU-allowed cost center \ No newline at end of file + prus_allowed_cost_center_name: "01 - PRU overages allowed" # Name for PRU-allowed cost center + +# Teams Integration Configuration +teams: + # Enable teams-based cost center management + enabled: false + + # Scope: 'organization' (org-level teams) or 'enterprise' (enterprise-level teams) + scope: "organization" # Options: "organization" or "enterprise" + + # Mode: 'auto' (one cost center per team) or 'manual' (use mappings below) + mode: "auto" # Options: "auto" or "manual" + + # Organizations to query for teams (only used when scope is 'organization') + organizations: [] + # - "my-org-1" + # - "my-org-2" + + # Auto-creation settings for teams mode + auto_create_cost_centers: true # Automatically create cost centers for teams + + # Cost center naming conventions (auto mode only): + # - Organization scope: [org team] {org-name}/{team-name} + # - Enterprise scope: [enterprise team] {team-name} + + # Orphaned user handling: remove users from cost centers if they're no longer in the team + remove_orphaned_users: true # Set to true to automatically remove orphaned users + + # Manual team-to-cost-center mappings (only used when mode is 'manual') + # Format: "org/team-slug": "cost_center_id" or "cost_center_name" (if auto_create enabled) + 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 diff --git a/config/config.yaml b/config/config.yaml index 253d569..b78950e 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -2,7 +2,7 @@ github: # Enterprise name is required. Prefer setting via environment var GITHUB_ENTERPRISE. # Leave as placeholder or blank if supplying via environment. - enterprise: "REPLACE_WITH_ENTERPRISE_SLUG" + enterprise: "" # Cost Center Settings cost_centers: @@ -13,8 +13,7 @@ cost_centers: prus_allowed_cost_center_id: "REPLACE_WITH_PRUS_ALLOWED_COST_CENTER_ID" # List of users to that should be added to cost center to receive PRUs - prus_exception_users: - # Add usernames and uncomment the lines below + prus_exception_users: [] # - "example.user1" # - "example.user2" @@ -23,6 +22,37 @@ cost_centers: no_prus_cost_center_name: "00 - No PRU overages" # Name for no-PRU cost center if auto-created prus_allowed_cost_center_name: "01 - PRU overages allowed" # Name for PRU-allowed cost center if auto-created +# Teams Integration Configuration +teams: + # Enable teams-based cost center management + enabled: false + + # Scope: 'organization' (org-level teams) or 'enterprise' (enterprise-level teams) + scope: "organization" # Options: "organization" or "enterprise" + + # Mode: 'auto' (one cost center per team) or 'manual' (use mappings below) + mode: "auto" # Options: "auto" or "manual" + + # Organizations to query for teams (only used when scope is 'organization') + organizations: + # - "your-org-name" + + # Auto-creation settings for teams mode + auto_create_cost_centers: true # Automatically create cost centers for teams + + # Cost center naming conventions (auto mode only): + # - Organization scope: [org team] {org-name}/{team-name} + # - Enterprise scope: [enterprise team] {team-name} + + # Orphaned user handling: remove users from cost centers if they're no longer in the team + remove_orphaned_users: true # Set to true to automatically remove orphaned users + + # Manual team-to-cost-center mappings (only used when mode is 'manual') + # Format: "org/team-slug": "cost_center_id" or "cost_center_name" (if auto_create enabled) + team_mappings: {} + # "my-org/frontend-team": "CC-FRONTEND-001" + # "my-org/backend-team": "CC-BACKEND-001" + # Logging Configuration logging: # Log level: DEBUG, INFO, WARNING, ERROR diff --git a/main.py b/main.py index 8032597..1e2523b 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,18 @@ #!/usr/bin/env python3 """ -Simplified GitHub Copilot Cost Center Management Script +GitHub Copilot Cost Center Management Script -This script manages GitHub Copilot license holders with a simple two-cost-center model: -- no_prus_cost_center_id: Default for all users -- prus_allowed_cost_center_id: Only for exception users listed in config +Automates cost center assignments for GitHub Copilot users with two operational modes: + +1. PRU-Based Mode: Simple two-tier model based on Premium Request Unit exceptions + - Default cost center for all users + - Exception cost center for specified PRU-allowed users + +2. Teams-Based Mode: Assigns users based on GitHub team membership + - Organization scope: Sync teams from specific GitHub organizations + - Enterprise scope: Sync teams across the entire GitHub Enterprise + - Automatic cost center creation and naming + - Orphaned user detection and removal """ import argparse @@ -15,6 +23,7 @@ from src.github_api import GitHubCopilotManager from src.cost_center_manager import CostCenterManager +from src.teams_cost_center_manager import TeamsCostCenterManager from src.config_manager import ConfigManager from src.logger_setup import setup_logging @@ -22,7 +31,22 @@ def parse_arguments(): """Parse command line arguments.""" parser = argparse.ArgumentParser( - description="Simplified GitHub Copilot Cost Center Management" + description="GitHub Copilot Cost Center Management - PRU-based or Teams-based assignment", + epilog=""" +Examples: + # PRU-based mode (default) + %(prog)s --assign-cost-centers --mode plan + %(prog)s --assign-cost-centers --mode apply --yes + + # Teams-based mode (organization scope) + %(prog)s --teams-mode --assign-cost-centers --mode plan + %(prog)s --teams-mode --assign-cost-centers --mode apply --yes + + # View configuration + %(prog)s --show-config + %(prog)s --teams-mode --show-config + """, + formatter_class=argparse.RawDescriptionHelpFormatter ) # Action arguments @@ -35,11 +59,9 @@ def parse_arguments(): parser.add_argument( "--assign-cost-centers", action="store_true", - help="Compute (and optionally apply) cost center assignments using simplified PRUs model" + help="Assign users to cost centers (use with --mode plan/apply)" ) - - parser.add_argument( "--show-config", action="store_true", @@ -49,13 +71,19 @@ def parse_arguments(): parser.add_argument( "--create-cost-centers", action="store_true", - help="Create cost centers if they don't exist (enterprise only)" + help="Create cost centers if they don't exist (PRU mode only)" ) parser.add_argument( "--incremental", action="store_true", - help="Only process users added since last run (ideal for cron jobs)" + help="Only process users added since last run (PRU mode only, ideal for cron jobs)" + ) + + parser.add_argument( + "--teams-mode", + action="store_true", + help="Enable teams-based assignment (alternative to PRU-based mode)" ) # Mode replaces --dry-run and --sync-cost-centers separation @@ -102,6 +130,131 @@ def parse_arguments(): return parser.parse_args() +def _handle_teams_mode(args, config: ConfigManager, teams_manager, logger) -> None: + """Handle teams-based cost center assignment mode.""" + + logger.info("="*60) + logger.info("TEAMS MODE - GitHub Teams Integration") + logger.info("="*60) + + # Show teams configuration + print("\n===== Teams Mode Configuration =====") + teams_scope = config.teams_scope # Already validated in main() + print(f"Scope: {teams_scope}") + print(f"Mode: {config.teams_mode}") + + if teams_scope == "enterprise": + print(f"Enterprise: {config.github_enterprise}") + else: + print(f"Organizations: {', '.join(config.teams_organizations)}") + + print(f"Auto-create cost centers: {config.teams_auto_create}") + print(f"Remove orphaned users: {config.teams_remove_orphaned_users}") + + if config.teams_mode == "auto": + if teams_scope == "enterprise": + print(f"Cost center naming: [enterprise team] {{team-name}}") + else: + print(f"Cost center naming: [org team] {{org-name}}/{{team-name}}") + elif config.teams_mode == "manual": + print(f"Manual mappings configured: {len(config.teams_mappings)}") + for team_key, cost_center in config.teams_mappings.items(): + print(f" - {team_key} → {cost_center}") + + print("===== End of Configuration =====\n") + + # Exit early if only showing config + if args.show_config and not any([args.assign_cost_centers, args.summary_report]): + return + + # Generate summary report if requested + if args.summary_report: + logger.info("Generating teams-based cost center summary...") + summary = teams_manager.generate_summary() + + teams_scope = config.teams_scope # Already validated in main() + + print("\n=== Teams Cost Center Summary ===") + print(f"Scope: {teams_scope}") + print(f"Mode: {summary['mode']}") + + if teams_scope == "enterprise": + print(f"Enterprise: {config.github_enterprise}") + else: + print(f"Organizations: {', '.join(summary['organizations'])}") + + print(f"Total teams: {summary['total_teams']}") + print(f"Cost centers: {summary['total_cost_centers']}") + print(f"Unique users: {summary['unique_users']}") + print(f"Note: Each user is assigned to exactly ONE cost center") + + if summary['cost_centers']: + print("\nPer-Cost-Center Breakdown:") + for cost_center, stats in summary['cost_centers'].items(): + print(f" {cost_center}: {stats['users']} users") + + # Assign cost centers if requested + if args.assign_cost_centers: + logger.info("Processing team-based cost center assignments...") + + if args.mode == "plan": + logger.info("MODE=plan (no changes will be made)") + + # Build and optionally sync assignments + if args.mode == "plan": + results = teams_manager.sync_team_assignments(mode="plan") + else: # apply mode + # Safety confirmation unless --yes provided + if not args.yes: + print("\n⚠️ WARNING: You are about to APPLY team-based cost center assignments!") + print("This will assign users to cost centers based on their team membership.") + print("NOTE: Each user can only belong to ONE cost center.") + print("Users in multiple teams will be assigned to the LAST team's cost center.") + confirm = input("\nProceed? Type 'apply' to continue: ").strip().lower() + if confirm != "apply": + logger.warning("Aborted by user before applying assignments") + return + + logger.info("Applying team-based assignments to GitHub Enterprise...") + results = teams_manager.sync_team_assignments(mode="apply") + + if results: + # Process detailed results for summary + total_users_attempted = 0 + total_users_successful = 0 + total_users_failed = 0 + + for cost_center_id, user_results in results.items(): + cc_successful = sum(1 for success in user_results.values() if success) + cc_failed = len(user_results) - cc_successful + total_users_attempted += len(user_results) + total_users_successful += cc_successful + total_users_failed += cc_failed + + if cc_failed > 0: + logger.warning(f"Cost center {cost_center_id}: {cc_successful}/{len(user_results)} users successful") + else: + logger.info(f"Cost center {cost_center_id}: all {cc_successful} users successful") + + # Final summary + if total_users_failed > 0: + logger.warning(f"FINAL RESULT: {total_users_successful}/{total_users_attempted} users successfully assigned ({total_users_failed} failed)") + else: + logger.info(f"FINAL RESULT: All {total_users_successful} users successfully assigned! 🎉") + + # Show success summary + print("\n" + "="*60) + print("🎉 TEAMS MODE SUCCESS SUMMARY") + print("="*60) + print(f" ✅ Team-based assignments completed") + print(f" 📊 Total users: {total_users_successful}/{total_users_attempted}") + if total_users_failed > 0: + print(f" ❌ Failed: {total_users_failed}") + print("="*60) + + logger.info("Teams mode execution completed successfully") + + def _show_success_summary(config: ConfigManager, args, users: Optional[List[Dict]] = None, original_user_count: Optional[int] = None, assignment_results: Optional[Dict] = None): """Show a comprehensive success summary at the end of execution.""" print("\n" + "="*60) @@ -194,8 +347,47 @@ def main(): logger.info("Configuration loaded successfully") - # Initialize managers + # Initialize GitHub manager github_manager = GitHubCopilotManager(config) + + # Check if teams mode is enabled (via flag or config) + teams_mode_enabled = args.teams_mode or config.teams_enabled + + if teams_mode_enabled: + # Validate teams configuration - scope is required + if not hasattr(config, 'teams_scope') or config.teams_scope is None: + logger.error("Teams mode requires 'scope' to be configured in config.teams.scope (must be 'organization' or 'enterprise')") + sys.exit(1) + + teams_scope = config.teams_scope + + # Validate scope value + if teams_scope not in ["organization", "enterprise"]: + logger.error(f"Invalid teams scope '{teams_scope}'. Must be 'organization' or 'enterprise'") + sys.exit(1) + + # Validate scope-specific requirements + if teams_scope == "organization": + if not config.teams_organizations: + logger.error("Teams mode with scope='organization' requires organizations to be configured in config.teams.organizations") + sys.exit(1) + elif teams_scope == "enterprise": + if not config.github_enterprise: + logger.error("Teams mode with scope='enterprise' requires enterprise to be configured in config.github_enterprise") + sys.exit(1) + + # Initialize teams manager + teams_manager = TeamsCostCenterManager(config, github_manager) + + scope_label = "enterprise" if teams_scope == "enterprise" else f"{len(config.teams_organizations)} organizations" + logger.info(f"Teams mode enabled: {config.teams_mode} mode with {teams_scope} scope ({scope_label})") + + # Handle teams mode flow + return _handle_teams_mode(args, config, teams_manager, logger) + + # ===== Standard PRU-based mode continues below ===== + + # Initialize cost center manager for PRU-based mode cost_center_manager = CostCenterManager(config, auto_create_enabled=args.create_cost_centers) # Always show configuration at the beginning of every run diff --git a/src/config_manager.py b/src/config_manager.py index 416a8f1..9961f18 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -107,6 +107,16 @@ def _load_config(self): self.enable_incremental = cost_center_config.get("enable_incremental", False) self.timestamp_file = Path(self.export_dir) / ".last_run_timestamp" + # Teams integration configuration + teams_config = config_data.get("teams", {}) + self.teams_enabled = teams_config.get("enabled", False) + self.teams_scope = teams_config.get("scope") # Required: "organization" or "enterprise" + self.teams_mode = teams_config.get("mode", "auto") # "auto" or "manual" + self.teams_organizations = teams_config.get("organizations", []) + self.teams_auto_create = teams_config.get("auto_create_cost_centers", True) + self.teams_mappings = teams_config.get("team_mappings", {}) + self.teams_remove_orphaned_users = teams_config.get("remove_orphaned_users", False) + # Store full config for other methods self.config = config_data diff --git a/src/github_api.py b/src/github_api.py index 9047f58..6bc5082 100644 --- a/src/github_api.py +++ b/src/github_api.py @@ -3,6 +3,7 @@ """ import logging +import re import time from typing import Dict, List, Optional from datetime import datetime @@ -46,7 +47,7 @@ def _create_session(self) -> requests.Session: session.headers.update({ "Authorization": f"token {self.config.github_token}", "Accept": "application/vnd.github+json", - "User-Agent": "Copilot-Cost-Center-Manager", + "User-Agent": "cost-center-automation", "X-GitHub-Api-Version": "2022-11-28" }) @@ -185,6 +186,8 @@ def get_user_details(self, username: str) -> Dict: def add_users_to_cost_center(self, cost_center_id: str, usernames: List[str]) -> Dict[str, bool]: """Add multiple users (up to 50) to a specific cost center. + Only adds users who are not already in the cost center to avoid unnecessary API calls. + Returns: Dict mapping username -> success status for detailed logging """ @@ -195,11 +198,30 @@ def add_users_to_cost_center(self, cost_center_id: str, usernames: List[str]) -> if len(usernames) > 50: self.logger.error(f"Cannot add more than 50 users at once. Got {len(usernames)} users.") return {username: False for username in usernames} + + # Get current members to avoid re-adding users who are already assigned + current_members = self.get_cost_center_members(cost_center_id) + current_members_set = set(current_members) + + # Filter to only users who need to be added + users_to_add = [u for u in usernames if u not in current_members_set] + users_already_assigned = [u for u in usernames if u in current_members_set] + + # Log users who are already assigned + if users_already_assigned: + self.logger.debug(f"Skipping {len(users_already_assigned)} users already in cost center {cost_center_id}: {users_already_assigned}") + + # If no users need to be added, return success for all + if not users_to_add: + self.logger.info(f"All {len(usernames)} users already assigned to cost center {cost_center_id}") + return {username: True for username in usernames} + + self.logger.info(f"Adding {len(users_to_add)} new users to cost center {cost_center_id} (skipping {len(users_already_assigned)} already assigned)") url = f"{self.base_url}/enterprises/{self.enterprise_name}/settings/billing/cost-centers/{cost_center_id}/resource" payload = { - "users": usernames + "users": users_to_add # Only send users who need to be added } # Set proper headers including API version @@ -221,21 +243,30 @@ def add_users_to_cost_center(self, cost_center_id: str, usernames: List[str]) -> return self.add_users_to_cost_center(cost_center_id, usernames) if response.status_code in [200, 201, 204]: - self.logger.info(f"✅ Successfully assigned {len(usernames)} users to cost center {cost_center_id}") - for username in usernames: + self.logger.info(f"✅ Successfully added {len(users_to_add)} users to cost center {cost_center_id}") + + for username in users_to_add: self.logger.info(f" ✅ {username} → {cost_center_id}") + + # Return success for all users (both added and already assigned) return {username: True for username in usernames} else: - self.logger.error(f"❌ Failed to assign users to cost center {cost_center_id}: {response.status_code} {response.text}") - for username in usernames: + self.logger.error(f"❌ Failed to add users to cost center {cost_center_id}: {response.status_code} {response.text}") + for username in users_to_add: self.logger.error(f" ❌ {username} → {cost_center_id} (API Error)") - return {username: False for username in usernames} + # Failed users get False, already assigned users get True + results = {username: False for username in users_to_add} + results.update({username: True for username in users_already_assigned}) + return results except requests.exceptions.RequestException as e: - self.logger.error(f"❌ Error assigning users to cost center {cost_center_id}: {str(e)}") - for username in usernames: + self.logger.error(f"❌ Error adding users to cost center {cost_center_id}: {str(e)}") + for username in users_to_add: self.logger.error(f" ❌ {username} → {cost_center_id} (Network Error)") - return {username: False for username in usernames} + # Failed users get False, already assigned users get True + results = {username: False for username in users_to_add} + results.update({username: True for username in users_already_assigned}) + return results def bulk_update_cost_center_assignments(self, cost_center_assignments: Dict[str, List[str]]) -> Dict[str, Dict[str, bool]]: """ @@ -298,6 +329,208 @@ def get_rate_limit_status(self) -> Dict: url = f"{self.base_url}/rate_limit" return self._make_request(url) + def list_org_teams(self, org: str) -> List[Dict]: + """ + List all teams in an organization. + + Args: + org: Organization name + + Returns: + List of team dictionaries with id, name, slug, description, etc. + """ + self.logger.info(f"Fetching teams for organization: {org}") + url = f"{self.base_url}/orgs/{org}/teams" + + all_teams = [] + page = 1 + per_page = 100 + + while True: + params = {"page": page, "per_page": per_page} + + try: + response_data = self._make_request(url, params) + + # Response is a list directly for teams endpoint + if not isinstance(response_data, list): + self.logger.error(f"Unexpected response format for teams: {type(response_data)}") + break + + teams = response_data + if not teams: + break + + all_teams.extend(teams) + self.logger.info(f"Fetched page {page} with {len(teams)} teams") + + page += 1 + + # Check if we have more pages + if len(teams) < per_page: + break + + except requests.exceptions.RequestException as e: + self.logger.error(f"Failed to fetch teams for org {org}: {str(e)}") + break + + self.logger.info(f"Total teams found in {org}: {len(all_teams)}") + return all_teams + + def get_team_members(self, org: str, team_slug: str) -> List[Dict]: + """ + Get all members of a specific team. + + Args: + org: Organization name + team_slug: Team slug (URL-friendly team name) + + Returns: + List of team member dictionaries with login, id, name, etc. + """ + self.logger.debug(f"Fetching members for team: {org}/{team_slug}") + url = f"{self.base_url}/orgs/{org}/teams/{team_slug}/members" + + all_members = [] + page = 1 + per_page = 100 + + while True: + params = {"page": page, "per_page": per_page} + + try: + response_data = self._make_request(url, params) + + # Response is a list directly for members endpoint + if not isinstance(response_data, list): + self.logger.error(f"Unexpected response format for team members: {type(response_data)}") + break + + members = response_data + if not members: + break + + all_members.extend(members) + self.logger.debug(f"Fetched page {page} with {len(members)} members for {org}/{team_slug}") + + page += 1 + + # Check if we have more pages + if len(members) < per_page: + break + + except requests.exceptions.RequestException as e: + self.logger.warning(f"Failed to fetch members for team {org}/{team_slug}: {str(e)}") + break + + self.logger.info(f"Total members found in {org}/{team_slug}: {len(all_members)}") + return all_members + + def list_enterprise_teams(self) -> List[Dict]: + """ + List all teams in the enterprise. + + Returns: + List of team dictionaries with id, name, slug, description, etc. + """ + if not self.use_enterprise or not self.enterprise_name: + 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 = [] + page = 1 + per_page = 100 + + while True: + params = {"page": page, "per_page": per_page} + + try: + response_data = self._make_request(url, params) + + # Response is a list directly for teams endpoint + if not isinstance(response_data, list): + self.logger.error(f"Unexpected response format for enterprise teams: {type(response_data)}") + break + + teams = response_data + if not teams: + break + + all_teams.extend(teams) + self.logger.info(f"Fetched page {page} with {len(teams)} enterprise teams") + + page += 1 + + # Check if we have more pages + if len(teams) < per_page: + break + + except requests.exceptions.RequestException as e: + 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]: + """ + Get all members of a specific enterprise team. + + Args: + team_slug: Team slug (URL-friendly team name) + + Returns: + List of team member dictionaries with login, id, name, etc. + """ + if not self.use_enterprise or not self.enterprise_name: + self.logger.error("Enterprise name required for fetching enterprise team members") + return [] + + self.logger.debug(f"Fetching members for enterprise team: {team_slug}") + url = f"{self.base_url}/enterprises/{self.enterprise_name}/teams/{team_slug}/memberships" + + all_members = [] + page = 1 + per_page = 100 + + while True: + params = {"page": page, "per_page": per_page} + + try: + response_data = self._make_request(url, params) + + # Response is a list directly for memberships endpoint + if not isinstance(response_data, list): + self.logger.error(f"Unexpected response format for enterprise team members: {type(response_data)}") + self.logger.debug(f"Response data: {response_data}") + break + + members = response_data + if not members: + break + + # Enterprise teams memberships endpoint returns user objects directly (not wrapped) + # Just add them all to our list + all_members.extend(members) + + self.logger.debug(f"Fetched page {page} with {len(members)} members for enterprise team {team_slug}") + + page += 1 + + # Check if we have more pages + if len(members) < per_page: + break + + except requests.exceptions.RequestException as e: + 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 create_cost_center(self, name: str) -> Optional[str]: """ Create a new cost center in the enterprise. @@ -342,9 +575,29 @@ def create_cost_center(self, name: str) -> Optional[str]: self.logger.info(f"Successfully created cost center '{name}' with ID: {cost_center_id}") return cost_center_id elif response.status_code == 409: - # Cost center already exists, find it by name - self.logger.info(f"Cost center '{name}' already exists, finding existing ID...") - return self._find_cost_center_by_name(name) + # Cost center already exists - try to extract UUID from error message first + self.logger.info(f"Cost center '{name}' already exists, extracting existing ID...") + + try: + response_data = response.json() + error_message = response_data.get('message', '') + + # Try to extract UUID from message: "...existing cost center UUID: ..." + uuid_pattern = r'existing cost center UUID:\s*([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})' + match = re.search(uuid_pattern, error_message, re.IGNORECASE) + + if match: + cost_center_id = match.group(1) + self.logger.info(f"Extracted existing cost center ID from API response: {cost_center_id}") + return cost_center_id + else: + self.logger.warning(f"Could not extract UUID from 409 response message: {error_message}") + self.logger.info("Falling back to name search to find existing cost center...") + return self._find_cost_center_by_name(name) + + except (ValueError, KeyError) as e: + self.logger.warning(f"Could not parse 409 response: {str(e)}, falling back to name search...") + return self._find_cost_center_by_name(name) else: self.logger.error(f"Failed to create cost center '{name}': {response.status_code} {response.text}") return None @@ -439,4 +692,98 @@ def ensure_cost_centers_exist(self, no_pru_cost_center_name: str = "00 - No PRU } self.logger.info(f"Cost centers ready - No PRU: {no_pru_id}, PRU Allowed: {pru_allowed_id}") - return result \ No newline at end of file + return result + + def get_cost_center_members(self, cost_center_id: str) -> List[str]: + """ + Get all members (usernames) currently assigned to a cost center. + + Args: + cost_center_id: The ID of the cost center + + Returns: + List of usernames currently in the cost center + """ + if not self.use_enterprise or not self.enterprise_name: + self.logger.error("Cost center operations only available for GitHub Enterprise") + return [] + + url = f"{self.base_url}/enterprises/{self.enterprise_name}/settings/billing/cost-centers/{cost_center_id}" + + try: + response_data = self._make_request(url) + + # The response contains a resources array with type and name fields + resources = response_data.get('resources', []) + usernames = [] + + for resource in resources: + # Each resource has 'type' (e.g., "User") and 'name' (username) + if resource.get('type') == 'User': + username = resource.get('name') + if username: + usernames.append(username) + + self.logger.debug(f"Cost center {cost_center_id} has {len(usernames)} members") + return usernames + + except requests.exceptions.RequestException as e: + self.logger.error(f"Failed to get members for cost center {cost_center_id}: {str(e)}") + return [] + + def remove_users_from_cost_center(self, cost_center_id: str, usernames: List[str]) -> Dict[str, bool]: + """ + Remove multiple users from a specific cost center. + + Args: + cost_center_id: The ID of the cost center + usernames: List of usernames to remove + + Returns: + Dict mapping username -> success status + """ + if not self.use_enterprise or not self.enterprise_name: + self.logger.error("Cost center operations only available for GitHub Enterprise") + return {user: False for user in usernames} + + if not usernames: + return {} + + url = f"{self.base_url}/enterprises/{self.enterprise_name}/settings/billing/cost-centers/{cost_center_id}/resource" + + payload = { + "users": usernames + } + + headers = { + "accept": "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + "content-type": "application/json" + } + + try: + response = self.session.delete(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.remove_users_from_cost_center(cost_center_id, usernames) + + if response.status_code in [200, 204]: + self.logger.info(f"✅ Successfully removed {len(usernames)} users from cost center {cost_center_id}") + for username in usernames: + self.logger.info(f" ✅ {username} removed from {cost_center_id}") + return {user: True for user in usernames} + else: + self.logger.error( + f"Failed to remove users from cost center {cost_center_id}: " + f"{response.status_code} {response.text}" + ) + return {user: False for user in usernames} + + except requests.exceptions.RequestException as e: + self.logger.error(f"Error removing users from cost center {cost_center_id}: {str(e)}") + return {user: False for user in usernames} \ No newline at end of file diff --git a/src/teams_cost_center_manager.py b/src/teams_cost_center_manager.py new file mode 100644 index 0000000..4cceb46 --- /dev/null +++ b/src/teams_cost_center_manager.py @@ -0,0 +1,508 @@ +""" +Teams-based Cost Center Manager for GitHub Teams integration. +""" + +import logging +from typing import Dict, List, Set, Tuple, Optional + + +class TeamsCostCenterManager: + """Manages cost center assignments based on GitHub team membership.""" + + def __init__(self, config, github_manager): + """ + Initialize the teams cost center manager. + + Args: + config: ConfigManager instance with teams configuration + github_manager: GitHubCopilotManager instance for API calls + """ + self.config = config + self.github_manager = github_manager + self.logger = logging.getLogger(__name__) + + # Teams configuration + self.teams_scope = config.teams_scope # "organization" or "enterprise" + self.teams_mode = config.teams_mode # "auto" or "manual" + self.organizations = config.teams_organizations + self.auto_create = config.teams_auto_create + self.team_mappings = config.teams_mappings or {} + + # Cache for team data + self.teams_cache: Dict[str, List[Dict]] = {} # org/enterprise -> list of teams + self.members_cache: Dict[str, List[str]] = {} # "org/team_slug" or "team_slug" -> list of usernames + self.cost_center_cache: Dict[str, str] = {} # "org/team_slug" or "team_slug" -> cost_center_id + + self.logger.info(f"Initialized TeamsCostCenterManager in '{self.teams_mode}' mode, scope '{self.teams_scope}'") + if self.teams_scope == "organization": + self.logger.info(f"Organizations: {', '.join(self.organizations) if self.organizations else 'None configured'}") + else: + self.logger.info(f"Enterprise scope: teams will be fetched from enterprise level") + + def fetch_all_teams(self) -> Dict[str, List[Dict]]: + """ + Fetch all teams based on configured scope (organization or enterprise). + + Returns: + Dict mapping org/enterprise name -> list of team dicts + """ + all_teams = {} + + if self.teams_scope == "enterprise": + # Fetch enterprise-level teams + enterprise_name = self.config.github_enterprise + self.logger.info(f"Fetching enterprise teams from: {enterprise_name}") + teams = self.github_manager.list_enterprise_teams() + all_teams[enterprise_name] = teams + self.teams_cache[enterprise_name] = teams + self.logger.info(f"Found {len(teams)} enterprise teams") + + else: # organization scope + if not self.organizations: + self.logger.warning("No organizations configured for organization scope") + return {} + + for org in self.organizations: + self.logger.info(f"Fetching teams from organization: {org}") + teams = self.github_manager.list_org_teams(org) + all_teams[org] = teams + self.teams_cache[org] = teams + self.logger.info(f"Found {len(teams)} teams in {org}") + + total_teams = sum(len(teams) for teams in all_teams.values()) + self.logger.info(f"Total teams: {total_teams}") + + return all_teams + + def fetch_team_members(self, org_or_enterprise: str, team_slug: str) -> List[str]: + """ + Fetch members of a specific team based on scope. + + Args: + org_or_enterprise: Organization or enterprise name + team_slug: Team slug + + Returns: + List of usernames (login names) + """ + if self.teams_scope == "enterprise": + # For enterprise teams, cache key is just the team slug + cache_key = team_slug + else: + # For org teams, cache key includes org + cache_key = f"{org_or_enterprise}/{team_slug}" + + if cache_key in self.members_cache: + return self.members_cache[cache_key] + + # Fetch members based on scope + if self.teams_scope == "enterprise": + members = self.github_manager.get_enterprise_team_members(team_slug) + else: + members = self.github_manager.get_team_members(org_or_enterprise, team_slug) + + usernames = [member.get('login') for member in members if member.get('login')] + + self.members_cache[cache_key] = usernames + return usernames + + def get_cost_center_for_team(self, org_or_enterprise: str, team: Dict) -> Optional[str]: + """ + Determine the cost center ID or name for a given team. + + Args: + org_or_enterprise: Organization or enterprise name + team: Team dictionary with name, slug, etc. + + Returns: + Cost center ID or name (for auto-creation) + """ + team_slug = team.get('slug') + team_name = team.get('name') + + # Build team key based on scope + if self.teams_scope == "enterprise": + team_key = team_slug # Enterprise teams don't need org prefix + else: + team_key = f"{org_or_enterprise}/{team_slug}" + + # Check cache first + if team_key in self.cost_center_cache: + return self.cost_center_cache[team_key] + + cost_center = None + + if self.teams_mode == "manual": + # Use manual mappings + cost_center = self.team_mappings.get(team_key) + + if not cost_center: + self.logger.warning( + f"No mapping found for team {team_key} in manual mode. " + "Team will be skipped. Add mapping to config.teams.team_mappings" + ) + return None + + elif self.teams_mode == "auto": + # Generate cost center name based on scope and naming standards + if self.teams_scope == "enterprise": + # Enterprise team mode: [enterprise team] {team-name} + cost_center = f"[enterprise team] {team_name}" + else: + # Organization team mode: [org team] {org-name}/{team-name} + cost_center = f"[org team] {org_or_enterprise}/{team_name}" + + else: + self.logger.error(f"Invalid teams mode: {self.teams_mode}. Must be 'auto' or 'manual'") + return None + + # Cache the result + self.cost_center_cache[team_key] = cost_center + return cost_center + + def build_team_assignments(self) -> Dict[str, List[Tuple[str, str, str]]]: + """ + Build complete team-to-members mapping with cost centers. + + IMPORTANT: Users can only belong to ONE cost center. If a user is in multiple teams, + they will be assigned to the LAST team's cost center that is processed. + + Returns: + Dict mapping cost_center -> list of (username, org, team_slug) tuples + """ + self.logger.info("Building team-based cost center assignments...") + + # Fetch all teams + all_teams = self.fetch_all_teams() + + if not all_teams: + self.logger.warning("No teams found in any configured organization") + return {} + + # Track final assignment per user (only ONE cost center per user) + user_assignments: Dict[str, Tuple[str, str, str]] = {} # username -> (cost_center, org, team_slug) + + # Track users across teams for conflict reporting + user_team_map: Dict[str, List[Tuple[str, str]]] = {} # username -> list of (org/team, cost_center) + + for org_or_enterprise, teams in all_teams.items(): + source_label = "enterprise" if self.teams_scope == "enterprise" else "organization" + self.logger.info(f"Processing {len(teams)} teams from {source_label}: {org_or_enterprise}") + + for team in teams: + team_name = team.get('name', 'Unknown') + team_slug = team.get('slug', 'unknown') + + # Build team key based on scope + if self.teams_scope == "enterprise": + team_key = team_slug + else: + team_key = f"{org_or_enterprise}/{team_slug}" + + # Get cost center for this team + cost_center = self.get_cost_center_for_team(org_or_enterprise, team) + + if not cost_center: + self.logger.debug(f"Skipping team {team_key} (no cost center mapping)") + continue + + # Fetch team members + self.logger.debug(f"Fetching members for team: {team_name} ({team_key})") + members = self.fetch_team_members(org_or_enterprise, team_slug) + + if not members: + self.logger.info(f"Team {team_key} has no members, skipping") + continue + + # Assign members to this cost center (will overwrite previous assignment) + for username in members: + # Track all teams this user belongs to for reporting + if username not in user_team_map: + user_team_map[username] = [] + user_team_map[username].append((team_key, cost_center)) + + # Set/overwrite the user's cost center assignment (last one wins) + user_assignments[username] = (cost_center, org_or_enterprise, team_slug) + + self.logger.info( + f"Team {team_name} ({team_key}) → Cost Center '{cost_center}': " + f"{len(members)} members" + ) + + # Report on multi-team users (conflicts where last assignment wins) + multi_team_users = {user: teams for user, teams in user_team_map.items() if len(teams) > 1} + if multi_team_users: + self.logger.warning( + f"⚠️ Found {len(multi_team_users)} users who are members of multiple teams. " + "Each user can only belong to ONE cost center - the LAST team processed will determine their assignment." + ) + for username, team_cc_list in list(multi_team_users.items())[:10]: # Show first 10 + teams_str = ", ".join([f"{team}" for team, cc in team_cc_list]) + final_cc = user_assignments[username][0] + self.logger.warning( + f" ⚠️ {username} is in multiple teams [{teams_str}] → " + f"will be assigned to '{final_cc}'" + ) + if len(multi_team_users) > 10: + self.logger.warning(f" ... and {len(multi_team_users) - 10} more multi-team users") + + # Convert to cost_center -> users mapping + assignments: Dict[str, List[Tuple[str, str, str]]] = {} + for username, (cost_center, org, team_slug) in user_assignments.items(): + if cost_center not in assignments: + assignments[cost_center] = [] + assignments[cost_center].append((username, org, team_slug)) + + # Summary + total_users = len(user_assignments) + + self.logger.info( + f"Team assignment summary: {len(assignments)} cost centers, " + f"{total_users} unique users (each assigned to exactly ONE cost center)" + ) + + return assignments + + def ensure_cost_centers_exist(self, cost_centers: Set[str]) -> Dict[str, str]: + """ + Ensure all required cost centers exist, creating them if needed. + + Args: + cost_centers: Set of cost center names or IDs + + Returns: + Dict mapping original name/ID -> actual cost center ID + """ + if not self.auto_create: + self.logger.info("Auto-creation disabled, assuming cost center IDs are valid") + # Return identity mapping (assume they're already IDs) + return {cc: cc for cc in cost_centers} + + self.logger.info(f"Ensuring {len(cost_centers)} cost centers exist...") + + cost_center_map = {} + + for cost_center_name in cost_centers: + # Try to create the cost center (will return existing ID if already exists) + cost_center_id = self.github_manager.create_cost_center(cost_center_name) + + if cost_center_id: + cost_center_map[cost_center_name] = cost_center_id + self.logger.debug(f"Cost center '{cost_center_name}' → ID: {cost_center_id}") + else: + self.logger.error(f"Failed to create/find cost center: {cost_center_name}") + # Use the name as fallback (will likely fail assignment but won't crash) + cost_center_map[cost_center_name] = cost_center_name + + self.logger.info(f"Successfully resolved {len(cost_center_map)} cost centers") + return cost_center_map + + def sync_team_assignments(self, mode: str = "plan") -> Dict[str, Dict[str, bool]]: + """ + Sync team-based cost center assignments to GitHub Enterprise. + + Args: + mode: "plan" (dry-run) or "apply" (actually sync) + + Returns: + Dict mapping cost_center_id -> Dict mapping username -> success status + """ + # Build assignments + team_assignments = self.build_team_assignments() + + if not team_assignments: + self.logger.warning("No team assignments to sync") + return {} + + # Get unique cost centers + cost_centers_needed = set(team_assignments.keys()) + + # Ensure cost centers exist (get ID mapping) - only in apply mode + if mode == "plan": + # In plan mode, just use the names as-is (no actual creation) + cost_center_id_map = {cc: cc for cc in cost_centers_needed} + self.logger.info(f"Plan mode: Would ensure {len(cost_centers_needed)} cost centers exist") + else: + cost_center_id_map = self.ensure_cost_centers_exist(cost_centers_needed) + + # Convert assignments to use actual cost center IDs + id_based_assignments: Dict[str, List[str]] = {} + + for cost_center_name, member_tuples in team_assignments.items(): + cost_center_id = cost_center_id_map.get(cost_center_name, cost_center_name) + + # Extract just usernames (deduplicate) + usernames = list(set(username for username, _, _ in member_tuples)) + + if cost_center_id not in id_based_assignments: + id_based_assignments[cost_center_id] = [] + + id_based_assignments[cost_center_id].extend(usernames) + + # Deduplicate usernames per cost center + for cost_center_id in id_based_assignments: + id_based_assignments[cost_center_id] = list(set(id_based_assignments[cost_center_id])) + + # Show summary + total_users = sum(len(users) for users in id_based_assignments.values()) + self.logger.info( + f"Prepared {len(id_based_assignments)} cost centers with {total_users} total user assignments" + ) + + if mode == "plan": + self.logger.info("MODE=plan: Would sync the following assignments:") + for cost_center_id, usernames in id_based_assignments.items(): + self.logger.info(f" {cost_center_id}: {len(usernames)} users") + + # In plan mode, show that orphaned users would be checked if the option is enabled + if self.config.teams_remove_orphaned_users: + self.logger.info("\nMODE=plan: Orphaned user detection is ENABLED") + self.logger.info(" In apply mode, users in cost centers but not in teams will be removed") + self.logger.info(" (Cannot show specific orphaned users in plan mode - cost centers don't exist yet)") + + return {} + + # Apply mode: actually sync + self.logger.info("Syncing team-based assignments to GitHub Enterprise...") + results = self.github_manager.bulk_update_cost_center_assignments(id_based_assignments) + + # Always check for orphaned users (detection), but only remove if configured + self.logger.info("Checking for orphaned users (users in cost centers but not in teams)...") + orphaned_results = self._remove_orphaned_users( + id_based_assignments, + cost_center_id_map, + remove=self.config.teams_remove_orphaned_users + ) + + # Merge orphaned user removal results into main results (if removal was enabled) + if self.config.teams_remove_orphaned_users: + for cost_center_id, user_results in orphaned_results.items(): + if cost_center_id not in results: + results[cost_center_id] = {} + results[cost_center_id].update(user_results) + + return results + + def _remove_orphaned_users(self, expected_assignments: Dict[str, List[str]], + cost_center_id_map: Dict[str, str], + remove: bool = True) -> Dict[str, Dict[str, bool]]: + """ + Detect and optionally remove orphaned users from cost centers. + + Orphaned users are those who are in a cost center but not in the corresponding team. + + Args: + expected_assignments: Dict mapping cost_center_id -> list of expected usernames + cost_center_id_map: Dict mapping cost_center_name -> cost_center_id + remove: If True, remove orphaned users. If False, only detect and log. + + Returns: + Dict mapping cost_center_id -> Dict mapping username -> removal success status + """ + removal_results = {} + total_orphaned = 0 + total_removed = 0 + + self.logger.info(f"Checking {len(expected_assignments)} cost centers for orphaned users...") + + for cost_center_id, expected_users in expected_assignments.items(): + # Get current members of the cost center + current_members = self.github_manager.get_cost_center_members(cost_center_id) + + # Debug logging + self.logger.debug(f"Cost center {cost_center_id}: {len(current_members)} current, {len(expected_users)} expected") + self.logger.debug(f" Current: {sorted(current_members)}") + self.logger.debug(f" Expected: {sorted(expected_users)}") + + # Find orphaned users (in cost center but not in expected team members) + expected_users_set = set(expected_users) + current_members_set = set(current_members) + orphaned_users = current_members_set - expected_users_set + + if orphaned_users: + # Find the cost center name for logging + cost_center_name = None + for name, cc_id in cost_center_id_map.items(): + if cc_id == cost_center_id: + cost_center_name = name + break + + display_name = cost_center_name or cost_center_id + + self.logger.warning( + f"⚠️ Found {len(orphaned_users)} orphaned users in cost center '{display_name}' " + f"(in cost center but not in team)" + ) + + for username in sorted(orphaned_users): + self.logger.warning(f" ⚠️ {username} is in cost center but not in team") + + total_orphaned += len(orphaned_users) + + # Remove orphaned users if configured + if remove: + self.logger.info(f"Removing {len(orphaned_users)} orphaned users from '{display_name}'...") + removal_status = self.github_manager.remove_users_from_cost_center( + cost_center_id, + list(orphaned_users) + ) + + removal_results[cost_center_id] = removal_status + successful_removals = sum(1 for success in removal_status.values() if success) + total_removed += successful_removals + + if successful_removals < len(orphaned_users): + failed = len(orphaned_users) - successful_removals + self.logger.warning( + f"Failed to remove {failed}/{len(orphaned_users)} orphaned users from '{display_name}'" + ) + else: + self.logger.info(f"⚠️ Orphaned user removal is DISABLED - users will remain in cost center") + + if total_orphaned > 0: + if remove: + self.logger.info( + f"📊 Orphaned users summary: Found {total_orphaned} orphaned users, " + f"successfully removed {total_removed}" + ) + else: + self.logger.warning( + f"📊 Orphaned users summary: Found {total_orphaned} orphaned users (NOT removed - removal disabled)" + ) + else: + self.logger.info("✅ No orphaned users found - all cost centers are in sync with teams") + + return removal_results + + def generate_summary(self) -> Dict: + """ + Generate a summary report of team-based assignments. + + Returns: + Dict with summary statistics + """ + team_assignments = self.build_team_assignments() + + # Get unique users across all cost centers (each user in exactly one) + all_users = set() + for members in team_assignments.values(): + for username, _, _ in members: + all_users.add(username) + + summary = { + "mode": self.teams_mode, + "organizations": self.organizations, + "total_teams": sum(len(teams) for teams in self.teams_cache.values()), + "total_cost_centers": len(team_assignments), + "unique_users": len(all_users), + "cost_centers": {} + } + + # Add per-cost-center breakdown + for cost_center, members in team_assignments.items(): + unique_members = set(username for username, _, _ in members) + summary["cost_centers"][cost_center] = { + "users": len(unique_members) + } + + return summary