workflows/kickstart-via-pr/systems/mcp/tools/pr-context/script.ps1

function Import-PrContextEnvironment {
    $envLocal = Join-Path $global:DotbotProjectRoot ".env.local"
    if (-not (Test-Path $envLocal)) {
        return
    }

    Get-Content $envLocal | ForEach-Object {
        if ($_ -match '^\s*([^#][^=]+)=(.*)$') {
            [Environment]::SetEnvironmentVariable($matches[1].Trim(), $matches[2].Trim(), "Process")
        }
    }
}

function Get-GitOutput {
    param([string[]]$Arguments)

    try {
        $result = & git @Arguments 2>$null
    } catch {
        return $null
    }

    $first = @($result | Select-Object -First 1)
    if ($first.Count -eq 0) {
        return $null
    }

    return [string]$first[0]
}

function Get-CurrentGitRemote {
    $remote = Get-GitOutput -Arguments @("remote", "get-url", "origin")
    if (-not $remote) {
        throw "Could not determine git remote origin for PR auto-detection."
    }

    return $remote.Trim()
}

function Get-CurrentGitBranch {
    $branch = Get-GitOutput -Arguments @("branch", "--show-current")
    if (-not $branch) {
        throw "Could not determine the current git branch for PR auto-detection."
    }

    return $branch.Trim()
}

function Convert-RemoteToGitHubInfo {
    param([string]$RemoteUrl)

    if ($RemoteUrl -match 'github\.com[:/](?<owner>[^/]+)/(?<repo>[^/]+?)(?:\.git)?$') {
        return @{
            owner = $matches["owner"]
            repo = $matches["repo"]
        }
    }

    return $null
}

function Convert-RemoteToAdoInfo {
    param([string]$RemoteUrl)

    $patterns = @(
        'https://(?:[^@/]+@)?dev\.azure\.com/(?<org>[^/]+)/(?<project>[^/]+)/_git/(?<repo>[^/]+?)(?:\.git)?$',
        'git@ssh\.dev\.azure\.com:v3/(?<org>[^/]+)/(?<project>[^/]+)/(?<repo>[^/]+?)(?:\.git)?$',
        'https://(?<org>[^/.]+)\.visualstudio\.com/(?<project>[^/]+)/_git/(?<repo>[^/]+?)(?:\.git)?$'
    )

    foreach ($pattern in $patterns) {
        if ($RemoteUrl -match $pattern) {
            return @{
                org = $matches["org"]
                project = $matches["project"]
                repo = $matches["repo"]
            }
        }
    }

    return $null
}

function Convert-PrUrlToGitHubInfo {
    param([string]$PullRequestUrl)

    if ($PullRequestUrl -match '^https://github\.com/(?<owner>[^/]+)/(?<repo>[^/]+)/pull/(?<number>\d+)(?:[/?#].*)?$') {
        return @{
            owner = $matches["owner"]
            repo = $matches["repo"]
            number = [int]$matches["number"]
        }
    }

    return $null
}

function Convert-PrUrlToAdoInfo {
    param([string]$PullRequestUrl)

    $patterns = @(
        '^https://dev\.azure\.com/(?<org>[^/]+)/(?<project>[^/]+)/_git/(?<repo>[^/]+)/pullrequest/(?<id>\d+)(?:[/?#].*)?$',
        '^https://(?<org>[^/.]+)\.visualstudio\.com/(?<project>[^/]+)/_git/(?<repo>[^/]+)/pullrequest/(?<id>\d+)(?:[/?#].*)?$'
    )

    foreach ($pattern in $patterns) {
        if ($PullRequestUrl -match $pattern) {
            return @{
                org = $matches["org"]
                project = $matches["project"]
                repo = $matches["repo"]
                id = [int]$matches["id"]
            }
        }
    }

    return $null
}

function Get-GitHubHeaders {
    $headers = @{
        "User-Agent" = "dotbot-pr-context"
        "Accept" = "application/vnd.github+json"
    }

    $token = if ($env:GITHUB_TOKEN) {
        $env:GITHUB_TOKEN
    } elseif ($env:GH_TOKEN) {
        $env:GH_TOKEN
    } else {
        $null
    }

    if ($token) {
        $headers["Authorization"] = "Bearer $token"
    }

    return $headers
}

function Get-AdoHeaders {
    if (-not $env:AZURE_DEVOPS_PAT) {
        throw "AZURE_DEVOPS_PAT not set in .env.local or environment."
    }

    $basicToken = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$($env:AZURE_DEVOPS_PAT)"))
    return @{
        "Authorization" = "Basic $basicToken"
        "Accept" = "application/json"
    }
}

function Invoke-GitHubRequest {
    param([string]$Uri)

    return Invoke-RestMethod -Method Get -Uri $Uri -Headers (Get-GitHubHeaders)
}

function Invoke-AdoRequest {
    param([string]$Uri)

    return Invoke-RestMethod -Method Get -Uri $Uri -Headers (Get-AdoHeaders)
}

function Convert-RefToBranchName {
    param([string]$RefName)

    if ($RefName -match '^refs/heads/(.+)$') {
        return $matches[1]
    }

    return $RefName
}

function Get-GitHubLinkedIssues {
    param(
        [string]$Owner,
        [string]$Repo,
        [string[]]$Texts
    )

    $issues = [System.Collections.ArrayList]::new()
    $seen = @{}

    foreach ($text in $Texts) {
        if ([string]::IsNullOrWhiteSpace($text)) {
            continue
        }

        foreach ($match in [regex]::Matches($text, '(?<issueOwner>[A-Za-z0-9_.-]+)/(?<issueRepo>[A-Za-z0-9_.-]+)#(?<number>\d+)')) {
            $issueOwner = $match.Groups["issueOwner"].Value
            $issueRepo = $match.Groups["issueRepo"].Value
            $number = $match.Groups["number"].Value
            $key = "$issueOwner/$issueRepo#$number"
            if ($seen.ContainsKey($key)) { continue }
            $seen[$key] = $true
            [void]$issues.Add(@{ owner = $issueOwner; repo = $issueRepo; number = $number; key = $key })
        }

        foreach ($match in [regex]::Matches($text, '(?<![A-Za-z0-9_.-/])#(?<number>\d+)')) {
            $number = $match.Groups["number"].Value
            $key = "$Owner/$Repo#$number"
            if ($seen.ContainsKey($key)) { continue }
            $seen[$key] = $true
            [void]$issues.Add(@{ owner = $Owner; repo = $Repo; number = $number; key = "#$number" })
        }
    }

    $resolvedIssues = @()
    foreach ($issue in $issues) {
        $issueUri = "https://api.github.com/repos/$($issue.owner)/$($issue.repo)/issues/$($issue.number)"
        try {
            $issueData = Invoke-GitHubRequest -Uri $issueUri
            $resolvedIssues += @{
                id = [int]$issueData.number
                key = $issue.key
                repository = "$($issue.owner)/$($issue.repo)"
                title = $issueData.title
                state = $issueData.state
                type = if ($issueData.pull_request) { "pull-request" } else { "issue" }
                url = $issueData.html_url
            }
        } catch {
            $resolvedIssues += @{
                id = [int]$issue.number
                key = $issue.key
                repository = "$($issue.owner)/$($issue.repo)"
                title = $null
                state = $null
                type = "issue"
                url = "https://github.com/$($issue.owner)/$($issue.repo)/issues/$($issue.number)"
            }
        }
    }

    return $resolvedIssues
}

function Get-GitHubChangedFiles {
    param(
        [string]$Owner,
        [string]$Repo,
        [int]$PullRequestNumber
    )

    $allFiles = [System.Collections.ArrayList]::new()
    $page = 1

    while ($true) {
        $fileUri = "https://api.github.com/repos/$Owner/$Repo/pulls/$PullRequestNumber/files?per_page=100&page=$page"
        $pageFiles = @(Invoke-GitHubRequest -Uri $fileUri)
        if ($pageFiles.Count -eq 0) {
            break
        }

        foreach ($file in $pageFiles) {
            [void]$allFiles.Add($file)
        }

        if ($pageFiles.Count -lt 100) {
            break
        }

        $page++
    }

    return @($allFiles)
}

function Convert-GitHubPrToResult {
    param(
        [string]$Owner,
        [string]$Repo,
        $PullRequest
    )

    $files = Get-GitHubChangedFiles -Owner $Owner -Repo $Repo -PullRequestNumber $PullRequest.number
    $changedFiles = @($files | ForEach-Object {
        @{
            path = $_.filename
            change_type = $_.status
        }
    })

    $linkedIssues = Get-GitHubLinkedIssues -Owner $Owner -Repo $Repo -Texts @($PullRequest.title, $PullRequest.body)

    return @{
        success = $true
        provider = "github"
        repository = "$Owner/$Repo"
        pr_url = $PullRequest.html_url
        pull_request_id = [int]$PullRequest.number
        title = $PullRequest.title
        description = if ($PullRequest.body) { $PullRequest.body } else { "" }
        state = $PullRequest.state
        author = if ($PullRequest.user) { $PullRequest.user.login } else { $null }
        source_branch = $PullRequest.head.ref
        target_branch = $PullRequest.base.ref
        linked_issues = @($linkedIssues)
        changed_files = $changedFiles
        message = "Loaded GitHub PR #$($PullRequest.number)"
    }
}

function Get-GitHubPrContextByUrl {
    param([string]$PullRequestUrl)

    $info = Convert-PrUrlToGitHubInfo -PullRequestUrl $PullRequestUrl
    if (-not $info) {
        throw "Invalid GitHub PR URL."
    }

    $prUri = "https://api.github.com/repos/$($info.owner)/$($info.repo)/pulls/$($info.number)"
    $pullRequest = Invoke-GitHubRequest -Uri $prUri
    return Convert-GitHubPrToResult -Owner $info.owner -Repo $info.repo -PullRequest $pullRequest
}

function Get-GitHubPrContextByCurrentBranch {
    $remote = Get-CurrentGitRemote
    $repoInfo = Convert-RemoteToGitHubInfo -RemoteUrl $remote
    if (-not $repoInfo) {
        throw "Current git remote is not a GitHub repository."
    }

    $branch = Get-CurrentGitBranch
    $listUri = "https://api.github.com/repos/$($repoInfo.owner)/$($repoInfo.repo)/pulls?head=$($repoInfo.owner):$branch&state=open"
    $pullRequests = @(Invoke-GitHubRequest -Uri $listUri)
    if ($pullRequests.Count -eq 0) {
        $listUri = "https://api.github.com/repos/$($repoInfo.owner)/$($repoInfo.repo)/pulls?head=$($repoInfo.owner):$branch&state=all"
        $pullRequests = @(Invoke-GitHubRequest -Uri $listUri)
    }

    if ($pullRequests.Count -eq 0) {
        throw "No GitHub pull request found for branch '$branch'."
    }

    return Convert-GitHubPrToResult -Owner $repoInfo.owner -Repo $repoInfo.repo -PullRequest $pullRequests[0]
}

function Get-AdoChangedFiles {
    param(
        [string]$Org,
        [string]$Project,
        [string]$Repo,
        [int]$PullRequestId
    )

    $iterationsUri = "https://dev.azure.com/$Org/$Project/_apis/git/repositories/$Repo/pullRequests/$PullRequestId/iterations?api-version=7.1"
    $iterations = Invoke-AdoRequest -Uri $iterationsUri
    $latestIteration = @($iterations.value | Sort-Object -Property id -Descending | Select-Object -First 1)
    if ($latestIteration.Count -eq 0) {
        return @()
    }

    $allChanges = [System.Collections.ArrayList]::new()
    $top = 2000
    $skip = 0

    while ($true) {
        $changesUri = "https://dev.azure.com/$Org/$Project/_apis/git/repositories/$Repo/pullRequests/$PullRequestId/iterations/$($latestIteration[0].id)/changes?`$compareTo=0&`$top=$top&`$skip=$skip&api-version=7.1"
        $changes = Invoke-AdoRequest -Uri $changesUri
        $entries = @($changes.changeEntries)

        foreach ($entry in $entries) {
            [void]$allChanges.Add(@{
                path = $entry.item.path
                change_type = $entry.changeType
            })
        }

        $nextSkip = 0
        if ($null -ne $changes.PSObject.Properties['nextSkip']) {
            $nextSkip = [int]$changes.nextSkip
        }

        if ($entries.Count -eq 0) {
            break
        }

        if ($nextSkip -gt $skip) {
            $skip = $nextSkip
            continue
        }

        if ($entries.Count -lt $top) {
            break
        }

        $skip += $top
    }

    return @($allChanges)
}

function Get-AdoLinkedIssues {
    param(
        [string]$Org,
        [string]$Project,
        [string]$Repo,
        [int]$PullRequestId
    )

    $workItemsUri = "https://dev.azure.com/$Org/$Project/_apis/git/repositories/$Repo/pullRequests/$PullRequestId/workitems?api-version=7.1"
    $workItems = Invoke-AdoRequest -Uri $workItemsUri
    $linkedIssues = @()

    foreach ($item in @($workItems.value)) {
        $detailUri = if ($item.url -match '\?') { "$($item.url)&api-version=7.1" } else { "$($item.url)?api-version=7.1" }
        try {
            $detail = Invoke-AdoRequest -Uri $detailUri
            $linkedIssues += @{
                id = [int]$detail.id
                key = "$($detail.id)"
                repository = "$Project/$Repo"
                title = $detail.fields.'System.Title'
                state = $detail.fields.'System.State'
                type = $detail.fields.'System.WorkItemType'
                url = if ($detail._links -and $detail._links.html) { $detail._links.html.href } else { $item.url }
            }
        } catch {
            $linkedIssues += @{
                id = [int]$item.id
                key = "$($item.id)"
                repository = "$Project/$Repo"
                title = $null
                state = $null
                type = "work-item"
                url = $item.url
            }
        }
    }

    return $linkedIssues
}

function Convert-AdoPrToResult {
    param(
        [string]$Org,
        [string]$Project,
        [string]$Repo,
        $PullRequest,
        [string]$ResolvedPrUrl
    )

    return @{
        success = $true
        provider = "azure-devops"
        repository = "$Project/$Repo"
        pr_url = $ResolvedPrUrl
        pull_request_id = [int]$PullRequest.pullRequestId
        title = $PullRequest.title
        description = if ($PullRequest.description) { $PullRequest.description } else { "" }
        state = $PullRequest.status
        author = if ($PullRequest.createdBy) { $PullRequest.createdBy.displayName } else { $null }
        source_branch = Convert-RefToBranchName -RefName $PullRequest.sourceRefName
        target_branch = Convert-RefToBranchName -RefName $PullRequest.targetRefName
        linked_issues = @(Get-AdoLinkedIssues -Org $Org -Project $Project -Repo $Repo -PullRequestId $PullRequest.pullRequestId)
        changed_files = Get-AdoChangedFiles -Org $Org -Project $Project -Repo $Repo -PullRequestId $PullRequest.pullRequestId
        message = "Loaded Azure DevOps PR $($PullRequest.pullRequestId)"
    }
}

function Get-AdoPrContextByUrl {
    param([string]$PullRequestUrl)

    $info = Convert-PrUrlToAdoInfo -PullRequestUrl $PullRequestUrl
    if (-not $info) {
        throw "Invalid Azure DevOps PR URL."
    }

    $prUri = "https://dev.azure.com/$($info.org)/$($info.project)/_apis/git/repositories/$($info.repo)/pullRequests/$($info.id)?api-version=7.1"
    $pullRequest = Invoke-AdoRequest -Uri $prUri
    return Convert-AdoPrToResult -Org $info.org -Project $info.project -Repo $info.repo -PullRequest $pullRequest -ResolvedPrUrl $PullRequestUrl
}

function Get-AdoPrContextByCurrentBranch {
    $remote = Get-CurrentGitRemote
    $repoInfo = Convert-RemoteToAdoInfo -RemoteUrl $remote
    if (-not $repoInfo) {
        throw "Current git remote is not an Azure DevOps repository."
    }

    $branch = Get-CurrentGitBranch
    $sourceRef = "refs/heads/$branch"
    $listUri = "https://dev.azure.com/$($repoInfo.org)/$($repoInfo.project)/_apis/git/repositories/$($repoInfo.repo)/pullrequests?searchCriteria.sourceRefName=$sourceRef&searchCriteria.status=active&api-version=7.1"
    $pullRequests = Invoke-AdoRequest -Uri $listUri
    $candidates = @($pullRequests.value)
    if ($candidates.Count -eq 0) {
        $listUri = "https://dev.azure.com/$($repoInfo.org)/$($repoInfo.project)/_apis/git/repositories/$($repoInfo.repo)/pullrequests?searchCriteria.sourceRefName=$sourceRef&searchCriteria.status=all&api-version=7.1"
        $pullRequests = Invoke-AdoRequest -Uri $listUri
        $candidates = @($pullRequests.value)
    }

    if ($candidates.Count -eq 0) {
        throw "No Azure DevOps pull request found for branch '$branch'."
    }

    $candidate = $candidates[0]
    $prUri = "https://dev.azure.com/$($repoInfo.org)/$($repoInfo.project)/_apis/git/repositories/$($repoInfo.repo)/pullRequests/$($candidate.pullRequestId)?api-version=7.1"
    $pullRequest = Invoke-AdoRequest -Uri $prUri
    $resolvedUrl = "https://dev.azure.com/$($repoInfo.org)/$($repoInfo.project)/_git/$($repoInfo.repo)/pullrequest/$($candidate.pullRequestId)"
    return Convert-AdoPrToResult -Org $repoInfo.org -Project $repoInfo.project -Repo $repoInfo.repo -PullRequest $pullRequest -ResolvedPrUrl $resolvedUrl
}

function Invoke-PrContext {
    param([hashtable]$Arguments)

    Import-PrContextEnvironment

    $prUrl = $Arguments["pr_url"]
    if ($prUrl) {
        $prUrl = $prUrl.Trim()
        if ($prUrl -match '^https://github\.com/') {
            return Get-GitHubPrContextByUrl -PullRequestUrl $prUrl
        }
        if ($prUrl -match '^https://(?:dev\.azure\.com|[^/.]+\.visualstudio\.com)/') {
            return Get-AdoPrContextByUrl -PullRequestUrl $prUrl
        }

        throw "Unsupported pull request URL. Use a GitHub or Azure DevOps PR URL."
    }

    $remote = Get-CurrentGitRemote
    if (Convert-RemoteToGitHubInfo -RemoteUrl $remote) {
        return Get-GitHubPrContextByCurrentBranch
    }
    if (Convert-RemoteToAdoInfo -RemoteUrl $remote) {
        return Get-AdoPrContextByCurrentBranch
    }

    throw "Could not auto-detect a supported pull request provider from the current git remote."
}