Public/GitMigration/Export-GitRepoInventory.ps1

function Export-GitRepoInventory {
    <#
    .SYNOPSIS
    Builds or refreshes the repository inventory/approval CSV for an Azure DevOps to
    GitHub migration.
 
    .DESCRIPTION
    Enumerates every project in the source organisation and every git repository in each
    project, and merges the result into the CSV at -Path. The CSV is the approval record
    the engagement runs on: the customer marks repositories Approved (and may edit the
    pre-filled TargetName) and Migrate-ReposToGitHub.ps1 migrates exactly the approved
    rows.
 
    Merge rules, keyed by SourceRepoId so renames on either side never duplicate a row:
      - Customer-owned columns (TargetName, Approved, Notes) on existing rows are
        preserved verbatim.
      - Fact columns (SourceProject, SourceRepo, DefaultBranch, SizeMB, IsDisabled,
        WebUrl, Status) are refreshed from the source on every run.
      - Rows whose repository has vanished from the source are kept as evidence and
        marked Status = MissingFromSource.
      - New repositories get a pre-filled TargetName: the slugified repository name, or
        the project-prefixed slug when that name is already claimed by another row (or,
        when -GitHubOrg is supplied, by an existing GitHub repository no row claims).
 
    Disabled repositories always have their existing rows refreshed, but only appear as
    NEW rows when -IncludeDisabled is passed - they cannot be cloned, so by default they
    do not enter the approval list.
 
    .PARAMETER Collection
    Source organisation URL, used verbatim (https://dev.azure.com/<org> or
    https://<org>.visualstudio.com).
 
    .PARAMETER Path
    Path of the inventory CSV to create or merge into. Commit this file: it is the
    engagement's approval record.
 
    .PARAMETER Pat
    Personal access token for the source organisation (Code Read). Omit to authenticate
    via Entra - the default across the module.
 
    .PARAMETER GitHubOrg
    Optional GitHub organisation to collision-check new TargetNames against, so a
    pre-filled name never silently collides with a repository that already exists there.
 
    .PARAMETER GitHubToken
    GitHub token for -GitHubOrg. Omit to use the signed-in gh CLI, then GITHUB_TOKEN
    (see Get-GitHubAccessToken).
 
    .PARAMETER IncludeDisabled
    Add rows for disabled source repositories too.
 
    .PARAMETER PassThru
    Also return the merged rows.
 
    .EXAMPLE
    Export-GitRepoInventory -Collection https://compucal.visualstudio.com -Pat $pat `
        -GitHubOrg 'CompuCal-Solutions' -GitHubToken $env:GITHUB_TOKEN `
        -Path .\repo-inventory.csv
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory)] [string]$Collection,
        [Parameter(Mandatory)] [string]$Path,
        [string]$Pat,
        [string]$GitHubOrg,
        [string]$GitHubToken,
        [switch]$IncludeDisabled,
        [switch]$PassThru,

        [switch]$UseDefaultCredentials
    )

    $columns = @('SourceProject', 'SourceRepo', 'SourceRepoId', 'DefaultBranch', 'SizeMB',
        'IsDisabled', 'WebUrl', 'Status', 'TargetName', 'Approved', 'Notes')

    # Existing rows, keyed by repo id. Rows are rebuilt with the full column set so an
    # inventory produced by an older version gains new columns without losing data.
    $rowsById = [ordered]@{}
    if (Test-Path -LiteralPath $Path) {
        foreach ($row in @(Import-Csv -LiteralPath $Path)) {
            if (-not ($row.PSObject.Properties['SourceRepoId'] -and $row.SourceRepoId)) { continue }
            $rebuilt = [ordered]@{}
            foreach ($column in $columns) {
                $rebuilt[$column] = if ($row.PSObject.Properties[$column]) { [string]$row.$column } else { '' }
            }
            $rowsById[[string]$row.SourceRepoId] = [pscustomobject]$rebuilt
        }
    }

    # Every name already claimed by a row is taken, case-insensitively - GitHub treats
    # 'Repo' and 'repo' as the same repository name.
    $claimedNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($row in $rowsById.Values) {
        if ($row.TargetName) { [void]$claimedNames.Add($row.TargetName) }
    }

    # Repositories that already exist in the GitHub org and are NOT claimed by a row
    # also block a pre-filled name: creating over them would land a migration in
    # somebody else's repository. A GitHub-side failure (no credential, expired SSO
    # session) downgrades to a warning - the inventory itself only needs the source.
    if ($GitHubOrg) {
        try {
            foreach ($ghRepo in @(Get-GitHubRepository -Org $GitHubOrg -Token $GitHubToken)) {
                if ($ghRepo -and $ghRepo.name -and -not $claimedNames.Contains($ghRepo.name)) {
                    [void]$claimedNames.Add($ghRepo.name)
                }
            }
        }
        catch {
            Write-Warning ("Could not list '{0}' on GitHub; new TargetNames are collision-checked against the CSV only. ({1})" -f `
                    $GitHubOrg, (@($_.Exception.Message -split "`n" | Where-Object { $_ -match '\S' })[0]))
        }
    }

    $seenIds = [System.Collections.Generic.HashSet[string]]::new()
    $newRows = 0

    Write-InfoLog "Enumerating projects in {collection}" -PropertyValues $Collection
    $projects = @(Get-TeamProject -Collection $Collection -Pat $Pat -UseDefaultCredentials:$UseDefaultCredentials)
    Write-Host ("==> {0} project(s) found in {1}" -f $projects.Count, $Collection) -ForegroundColor Cyan

    foreach ($project in $projects) {
        $repos = @(Get-GitRepository -Collection $Collection -Project $project.name -Pat $Pat `
                -UseDefaultCredentials:$UseDefaultCredentials -IncludeDisabled)
        Write-Host (" {0}: {1} repo(s)" -f $project.name, $repos.Count) -ForegroundColor DarkGray

        foreach ($repo in $repos) {
            $id = [string]$repo.id
            [void]$seenIds.Add($id)

            $isDisabled = [bool]($repo.PSObject.Properties['isDisabled'] -and $repo.isDisabled)
            $defaultBranch = if ($repo.PSObject.Properties['defaultBranch']) { [string]$repo.defaultBranch } else { '' }
            $sizeMB = if ($repo.PSObject.Properties['size'] -and $repo.size) { [math]::Round([int64]$repo.size / 1MB, 2) } else { 0 }
            $webUrl = if ($repo.PSObject.Properties['webUrl']) { [string]$repo.webUrl } else { '' }
            $status = if ($isDisabled) { 'Disabled' } else { 'Active' }

            if ($rowsById.Contains($id)) {
                # Refresh the facts; never touch the customer-owned columns.
                $row = $rowsById[$id]
                $row.SourceProject = $project.name
                $row.SourceRepo = $repo.name
                $row.DefaultBranch = $defaultBranch
                $row.SizeMB = $sizeMB
                $row.IsDisabled = $isDisabled
                $row.WebUrl = $webUrl
                $row.Status = $status
                continue
            }

            if ($isDisabled -and -not $IncludeDisabled) { continue }

            # Pre-fill a collision-free TargetName: the repo slug, then the
            # project-prefixed slug, then a numeric suffix as a last resort.
            $targetName = ConvertTo-GitHubRepoName -Name $repo.name
            if ($claimedNames.Contains($targetName)) {
                $targetName = ConvertTo-GitHubRepoName -Name ('{0}-{1}' -f $project.name, $repo.name)
            }
            $suffix = 1
            $candidate = $targetName
            while ($claimedNames.Contains($candidate)) {
                $suffix++
                $candidate = '{0}-{1}' -f $targetName, $suffix
            }
            $targetName = $candidate
            [void]$claimedNames.Add($targetName)

            $rowsById[$id] = [pscustomobject][ordered]@{
                SourceProject = $project.name
                SourceRepo    = $repo.name
                SourceRepoId  = $id
                DefaultBranch = $defaultBranch
                SizeMB        = $sizeMB
                IsDisabled    = $isDisabled
                WebUrl        = $webUrl
                Status        = $status
                TargetName    = $targetName
                Approved      = ''
                Notes         = ''
            }
            $newRows++
        }
    }

    # Rows whose repository no longer answers on the source: evidence, not deletions.
    $missing = 0
    foreach ($row in $rowsById.Values) {
        if (-not $seenIds.Contains([string]$row.SourceRepoId)) {
            $row.Status = 'MissingFromSource'
            $missing++
        }
    }

    $rows = @($rowsById.Values)
    $approved = @($rows | Where-Object { $_.Approved -match '^(?i)(y|yes|true|1)$' }).Count

    if ($PSCmdlet.ShouldProcess($Path, 'Write repository inventory CSV')) {
        $directory = Split-Path -Parent $Path
        if ($directory -and -not (Test-Path -LiteralPath $directory)) {
            New-Item -ItemType Directory -Path $directory -Force | Out-Null
        }
        $rows | Select-Object $columns | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding UTF8
        Write-FixStep ("Inventory written: {0} - {1} repo(s) total, {2} new, {3} approved, {4} missing from source" -f `
                $Path, $rows.Count, $newRows, $approved, $missing)
    }

    if ($PassThru) { return $rows }
}