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