Modules/businessdev.ALbuild.Feeds/Public/Resolve-BcDevOpsContext.ps1

function Resolve-BcDevOpsContext {
    <#
    .SYNOPSIS
        Resolves the Azure DevOps organization and project a workspace belongs to.
 
    .DESCRIPTION
        A project-scoped Universal feed can only be reached with BOTH an organization and a project. Azure
        DevOps answers a request for a project-scoped feed made in organization scope with
        'TF1600011: The feed with ID ... doesn't exist' - not "no access" - so a missing project surfaces
        as a feed that appears not to exist at all, and the search for the cause goes the wrong way.
 
        The org/project pair was derived independently in five places: the CLI backend, two MCP call
        sites, and two Azure DevOps tasks. The git-based ones parsed the remote themselves (one of them
        with an HTTPS-only regex, so an SSH clone yielded no project), while the task-based ones read the
        pipeline environment and never looked at git. This is the one place that does both:
 
          1. the workspace's 'git remote get-url origin', parsed by ConvertFrom-BcRemoteUrl (which knows
             HTTPS, user@HTTPS, legacy visualstudio.com, SSH v3 and SSH host aliases), then
          2. the Azure Pipelines environment (SYSTEM_TEAMFOUNDATIONCOLLECTIONURI / SYSTEM_TEAMPROJECT),
             which is authoritative when there is no git remote to read - a pipeline that checks out
             without origin, or a workspace assembled from an artifact.
 
        Both parts are optional and independent: git may give the organization while the environment
        gives the project, and either may come back empty. Empty is a valid answer - the caller then
        falls back to organization scope instead of failing - so this never throws.
 
    .PARAMETER WorkspaceRoot
        The repository folder whose 'origin' remote is read. Omit to skip the git step entirely (useful
        when only the environment should be consulted).
 
    .PARAMETER GitExecutable
        The git executable to use (default 'git').
 
    .OUTPUTS
        PSCustomObject with Organization (a full https://dev.azure.com/{org}/ URL, or '') and Project
        (the project name, or ''), plus Source ('git', 'environment', 'git+environment' or 'none') so a
        caller can log where the answer came from.
 
    .EXAMPLE
        $ctx = Resolve-BcDevOpsContext -WorkspaceRoot 'D:\Code\DevOps\businessdev.Api.Banking'
        Resolve-BcExternalDependency -UniversalPackages $cfg.UniversalPackages -Organization $ctx.Organization -Project $ctx.Project -OutputFolder $staging
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string] $WorkspaceRoot,
        [string] $GitExecutable = 'git'
    )

    $organization = ''
    $project = ''
    $fromGit = $false

    if ($WorkspaceRoot -and (Test-Path -LiteralPath $WorkspaceRoot)) {
        # Best-effort: no git, no repository, no 'origin' - all mean "nothing to learn here", not a failure.
        $remote = ''
        try {
            $remote = "$(& $GitExecutable -C $WorkspaceRoot remote get-url origin 2>$null)".Trim()
        }
        catch { $remote = '' }

        if ($remote) {
            $parsed = ConvertFrom-BcRemoteUrl -RemoteUrl $remote
            if ($parsed.Organization) { $organization = $parsed.Organization; $fromGit = $true }
            if ($parsed.Project) { $project = $parsed.Project; $fromGit = $true }
        }
    }

    # The pipeline environment fills only what git did not answer, so an explicit remote keeps winning
    # for a repository built outside its own project.
    $fromEnv = $false
    if (-not $organization -and $env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) {
        $organization = $env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI
        $fromEnv = $true
    }
    if (-not $project -and $env:SYSTEM_TEAMPROJECT) {
        $project = $env:SYSTEM_TEAMPROJECT
        $fromEnv = $true
    }

    $source = if ($fromGit -and $fromEnv) { 'git+environment' }
    elseif ($fromGit) { 'git' }
    elseif ($fromEnv) { 'environment' }
    else { 'none' }

    return [PSCustomObject]@{
        Organization = $organization
        Project      = $project
        Source       = $source
    }
}