listFeatureCost.ps1

<#
.SYNOPSIS
List cost and billed amount by feature
 
.DESCRIPTION
Lists all Features within a Project and summarizes the timerecord data under those features (userstories, bugs and tasks).
 
.PARAMETER devOps
    The DevOps the project is in
 
.PARAMETER devOpsProject
    Project to fetch data for.
 
.PARAMETER tcProjects
    Projects in TC to resolve the DevOps project to. If not given the same name as the devOpsProject is used.
 
.PARAMETER from
    Optional Parameter to consider costs only from the given date on. If not given, all costs in past will be taken.
 
.PARAMETER to
    Optional Parameter to consider costs only up to the given date. If not given, costs until today will be taken.
 
.PARAMETER outputFile
    Optional file to write results as CSV to.
    If targetGroup is set to "internal" then a "confidential" suffix is added to the provided filename.
 
.PARAMETER targetGroup
    If report contains internal data (cost column and non-billable bookings).
    Can be set to internal and external. Defaults to external
    If set to "internal" then a "confidential" suffix is added to the outputFile filename.
 
.PARAMETER tcDBCredential
    Credential for Timecockpit DB. If not given they will be requested and stored, if used, in CredentialManager.
 
.PARAMETER devOpsCredential
    Credential for DevOps. If not given they will be requested and stored, if used, in CredentialManager.
     
.PARAMETER useCredentialsManager
    If credentialmanager should be used. Default is $True.
 
.EXAMPLE
    Creates statistic and write to CSV for further usage in Excel
     
    .\listFeatureCost.ps1 -devOps Rolsped -devOpsProject TruckerApp -tcProjects "TruckerApp" -outputFile "c:\temp\features.csv"
 
.EXAMPLE
    Creates statistic for DevOps project having different named and multiple TCproject
     
    .\listFeatureCost.ps1 -devOps GuidNew -devOpsProject Opticon -tcProjects "Service-App","Extension_102019" -outputFile "c:\temp\features.csv"
 
#>

[CmdletBinding(SupportsShouldProcess=$true)]
param(
    [Parameter(Mandatory=$true)]
    [ValidateSet('GuidNew','Rolsped')]
    [string] $devOps,
    [Parameter(Mandatory=$true)]
    [string] $devOpsProject,
    [string[]] $tcProjects = @($devOpsProject),
    [DateTime] $from,
    [DateTime] $to,
    [System.IO.FileInfo] $outputFile,
    [ValidateSet('internal','external')]
    [string] $targetGroup = "external",
    [PSCredential] $tcDBCredential,
    [PSCredential] $devOpsCredential,
    [Boolean] $useCredentialsManager = $True
)

function Get-PAT
{
    Param(
       [Parameter(Mandatory=$true)]
       [ValidateSet('GuidNew','Rolsped')]
       [string] $devOps,
       [PSCredential]$Credential,
       [Boolean]$UseCredentialsManager = $False
    ) #end param

    # check module prerequisites
    if($UseCredentialsManager)
    {
        $module = Get-Module -ListAvailable -Name "CredentialsManager";
        if (!$module) { throw "Module 'CredentialsManager' needed. Please install executing 'Install-Module -Name CredentialsManager' as Administrator."; }
    }
    
    if($UseCredentialsManager -and $Credential -eq $Null)
    {
        $Credential = Read-Credential -ListAvailable | Where { $_.Environment -eq "DevOps.${devOps}" }
    }
    if(!$Credential) { $Credential = Get-Credential -Message "Please enter Credentials for ${devOps} DevOps."; }
    if($UseCredentialsManager) 
    { 
        Write-Credential "DevOps.${devOps}" -Credential $Credential; 
    }

    return $Credential;
}

function Get-ItemsByIds([int[]] $ids)
{
    if($ids.Count -eq 0) { return @() }

    $allItems= @()
    for($i = 0; $i -lt $ids.Count;$i += 200)
    {
        $idsList = $ids[$i..($i+199)] -join ","

        $devOpsGetCall = "${devOpsUrl}/_apis/wit/workitems?ids=${idsList}&fields=System.Id,System.Title,System.State,System.WorkItemType,System.AreaPath,Microsoft.VSTS.Scheduling.StoryPoints&api-version=5.1";
        $items = Invoke-RestMethod -Uri $devOpsGetCall -Method Get -ContentType "application/json" -Headers @{Authorization=("Basic {0}" -f $base64AuthInfo)}
        $allItems += $items
    }
    
    return $allItems
}

function Get-Items([string] $wiql)
{
    $devOpsCall = "${devOpsUrl}/_apis/wit/wiql?api-version=5.1"
    $content = ConvertTo-JSON( @{ query=$wiql; });

    $returned = Invoke-RestMethod -Uri $devOpsCall -Method Post -Body $content -ContentType "application/json" -Headers @{Authorization=("Basic {0}" -f $base64AuthInfo)}
    
    $ids = $returned.workItems | Select-Object -ExpandProperty Id
    if($ids -eq $Null) { return @(); }
    $items = Get-ItemsByIds $ids 
    
    return $items
}

function Get-ItemsRelation([string] $wiql)
{
    $devOpsCall = "${devOpsUrl}/_apis/wit/wiql?api-version=5.1"
    $content = ConvertTo-JSON( @{ query=$wiql; });

    $returned = Invoke-RestMethod -Uri $devOpsCall -Method Post -Body $content -ContentType "application/json" -Headers @{Authorization=("Basic {0}" -f $base64AuthInfo)}
    
    $ids = $returned.workItemRelations | Select-Object -ExpandProperty Target | Select-Object -ExpandProperty Id
    if($ids -eq $Null) { return @(); }
    $items = Get-ItemsByIds $ids 
    
    return $items
}

# check prerequisites
$tcModule = Get-Module -ListAvailable -Name "TimeCockpit";
if (!$tcModule) { throw "Module 'TimeCockpit' needed. Please install executing 'Install-Module -Name TimeCockpit' as Administrator."; }
Write-Information "Using TimeCockpit Module v$($tcModule.Version)."

# process parameters
if($devOps -eq "GuidNew")
{
    $devOpsUrl = "https://dev.azure.com/guidnew"
}
if($devOps -eq "Rolsped")
{
    $devOpsUrl = "https://dev.azure.com/rolsped"
}
if(-not $from) { $from = [DateTime]::MinValue }
if(-not $to) 
{
    $to = [DateTime]::MaxValue
}
else 
{
    # set time of to to end of day
    $to = $to.Date.AddDays(1).AddSeconds(-1)
}

# Authentication Token for DevOps
$devOpsCredential = Get-PAT $devOps $devOpsCredential $useCredentialsManager
$base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $devOpsCredential.UserName, $devOpsCredential.GetNetworkCredential().password)))

$tcProjectsResolved = $tcProjects | foreach { "'" + $_ + "'" }

$query = @"
with ResolvedTimesheet as
(
    select
        Customer.APP_Code as 'CustomerCode',
        Project.APP_Code as 'ProjectCode',
        Task.APP_TaskUuid as 'TaskUuid',
        Task.APP_Code as 'TaskCode',
        CASE
            WHEN CHARINDEX('-', Task.APP_Code) > 0
                THEN LEFT(Task.APP_Code, CHARINDEX('-', Task.APP_Code) - 1)
                ELSE Task.APP_Code
            END AS 'BacklogItemId',
        Task.APP_Description as 'TaskDescription',
        Timesheet.APP_BeginTime as 'BeginTime',
        Timesheet.APP_EndTime as 'EndTime',
        Task.APP_Closed as 'TaskClosed',
        case
            when Task.APP_NoBilling = 1 then cast(1 as bit)
            else Timesheet.APP_NoBilling
        end as 'TimesheetNoBilling',
        Timesheet.APP_HourlyRateBilled as 'TimesheetBilled',
        isnull(cast(DATEDIFF(minute, Timesheet.APP_BeginTime, Timesheet.APP_EndTime) as float), 0) as 'Duration',
        case
            when Task.APP_NoBilling = 1 or Timesheet.APP_NoBilling = 1 then 0
            else isnull(cast(DATEDIFF(minute, Timesheet.APP_BeginTime, Timesheet.APP_EndTime) as float), 0)
        end as 'DurationBilled',
        case
            when Task.APP_NoBilling = 1 or Timesheet.APP_NoBilling = 1 then isnull(cast(DATEDIFF(minute, Timesheet.APP_BeginTime, Timesheet.APP_EndTime) as float), 0)
            else 0
        end as 'DurationUnBilled',
        case
            when Timesheet.APP_HourlyRate is null and Task.APP_HourlyRate is null and Project.APP_HourlyRate is null then Customer.APP_HourlyRate
            when Timesheet.APP_HourlyRate is null and Task.APP_HourlyRate is null then Project.APP_HourlyRate
            when Timesheet.APP_HourlyRate is null then Task.APP_HourlyRate
            else Timesheet.APP_HourlyRate
        end as 'HourlyRate',
        Timesheet.USR_CostRate as 'TimesheetCostrate'
    from edhrqnfxua.APP_Timesheet as Timesheet
        join edhrqnfxua.APP_Project as Project on Project.APP_ProjectUuid = Timesheet.Relation_APP_Project
        join edhrqnfxua.APP_Customer as Customer on Customer.APP_CustomerUuid = Project.Relation_APP_Customer
        left join edhrqnfxua.APP_Task as Task on Task.APP_TaskUuid = Timesheet.Relation_APP_Task
    where Project.APP_Code IN ($($tcProjectsResolved -join ','))
        and Timesheet.APP_BeginTime > '$($from.ToString('yyyy-MM-dd'))'
        and Timesheet.APP_EndTime <= '$($to.ToString('yyyy-MM-dd HH:mm'))'
)
select CustomerCode,
    ProjectCode,
    BacklogItemId,
    min(BeginTime) BeginTime,
    max(EndTime) EndTime,
    sum(Duration) / 60 Duration,
    sum(DurationBilled) / 60 as DurationBilled,
    sum(DurationUnbilled) / 60 as DurationUnbilled,
    sum((DurationBilled / 60.0) * HourlyRate) as AmountBilled,
    sum((Duration / 60.0) * TimesheetCostrate) Cost,
    max(HourlyRate) HourlyRate
from ResolvedTimesheet TS
group by CustomerCode, ProjectCode, BacklogItemId
order by CustomerCode desc, max(EndTime)
"@


Connect-TCDB -Credential $tcDBCredential -UseCredentialsManager $useCredentialsManager;

Write-Output "Fetching Timerecords from DB...";
$items = (Invoke-TCDBSqlCmd -Query $query)
Write-Output "Fetched $($items.Count) records.";

Write-Output "Fetching Features from DevOps...";
$features = Get-Items "Select [System.Id], [System.Title], [System.State] From WorkItems Where [System.WorkItemType] = 'Feature' and [System.TeamProject] = '${devOpsProject}' "
Write-Output "Fetched $($features.Count) features.";

$unparented = Get-Items "select [System].[Id], [System.Title], [System.AreaPath] from WorkItemLinks where [Source].[System.TeamProject] = '${devOpsProject}' AND [Source].[System.WorkItemType] IN ('User Story', 'Bug') AND [System.Links.LinkType] = 'System.LinkTypes.Hierarchy-Reverse' and [Target].[System.WorkItemType] IN ('Feature') mode (DoesNotContain)"
Write-Output "Fetched $($unparented.Count) unparented User Stories and Bugs.";

$output = @()
$cleanupHints = @()

# summarize unparented
$duration = 0
$amountBilled = 0
$cost = 0
foreach ($orphan in $unparented.value)
{
    $item = $items | where { $_.BacklogItemId -eq $orphan.id }

    $orphanTitle = $orphan.fields."System.Title"
    $orphanAreaPath = $child.fields."System.AreaPath"
    if($orphanAreaPath.Length -gt $devOpsProject.Length) 
    { 
        $orphanAreaPath = $orphanAreaPath.SubString($devOpsProject.Length + 1, $orphanAreaPath.Length - $devOpsProject.Length - 1) 
    }
    
    Write-Output " $($orphan.id) (${orphanAreaPath} ${orphanTitle}) has a duration of $($item.Duration) hours, Billed Amount of $($item.AmountBilled), Cost of $($item.Cost).";
    
    $duration += $item.Duration;
    $amountBilled += $item.AmountBilled;
    $cost += $item.Cost;
    
    $row = [pscustomobject][ordered]@{ Feature=$Null; FeatureTitle="_Unparented"; Child=$orphan.id; ChildTitle=$orphanTitle; AreaPath=$orphanAreaPath; Duration=[math]::Round($item.duration, 2); AmountBilled=[math]::Round($item.AmountBilled, 2); }
    if($targetGroup -eq "internal")    { Add-Member -InputObject $row -NotePropertyMembers @{ Cost=[math]::Round($item.Cost, 2);} }

    $output += $row
    
    if($item.Duration -gt 0) {  $cleanupHints += "Assign a parent feature for Backlogitem $($orphan.id)." }
}
Write-Output "Unparented have a duration of ${duration} hours, Billed Amount of ${amountBilled}, Cost of ${cost}.";

# summarize features
foreach ($feature in $features.value)
{
    $featureTitle = $feature.fields."System.Title"
    
    $children = Get-ItemsRelation "select [System].[Id], [System.Title], [System.AreaPath] from WorkItemLinks where [Source].[System.Id] = '$($feature.id)' AND [System.Links.LinkType] = 'System.LinkTypes.Hierarchy-Forward' and [Target].[System.WorkItemType] IN ('Bug', 'User Story')"
    Write-Output "Fetched $($children.Count) User Stories and Bugs for feature $($feature.id) (${featureTitle}).";
    $duration = 0
    $amountBilled = 0
    $cost = 0
    foreach ($child in $children.value)
    {
        $item = $items | where { $_.BacklogItemId -eq $child.id }

        if($child.Id -eq $feature.Id) 
        {
            if($item.Duration -gt 0)
            {
                $cleanupHints += "The item $($child.Id) is a Feature, but has bookings. Please correct TC hierarchy that Stories/Bugs are rootlevel."
            }
        }
        
        $childTitle = $child.fields."System.Title"
        $childAreaPath = $child.fields."System.AreaPath"
        if($childAreaPath.Length -gt $devOpsProject.Length) 
        { 
            $childAreaPath = $childAreaPath.SubString($devOpsProject.Length + 1, $childAreaPath.Length - $devOpsProject.Length - 1) 
        }
        
        Write-Output " $($child.id) (${childAreaPath} ${childTitle}) has a duration of $($item.Duration) hours, Billed Amount of $($item.AmountBilled), Cost of $($item.Cost).";
        
        $duration += $item.Duration;
        $amountBilled += $item.AmountBilled;
        $cost += $item.Cost;
        
        $row = [pscustomobject][ordered]@{ Feature=$feature.id; FeatureTitle=$featureTitle; Child=$child.id; ChildTitle=$childTitle; AreaPath=$childAreaPath; Duration=[math]::Round($item.duration, 2); AmountBilled=[math]::Round($item.AmountBilled, 2); }
        if($targetGroup -eq "internal")    { Add-Member -InputObject $row -NotePropertyMembers @{ Cost=[math]::Round($item.Cost, 2);} }

        $output += $row
    }
    Write-Output "$($feature.id) has a duration of ${duration} hours, Billed Amount of ${amountBilled}, Cost of ${cost}.";
}

# summarize bookings not assigned to any devops backlogitem
$nonBacklogitems = $items | where {($_.BacklogItemId -eq [System.DBNull]::Value) -or ($_.BacklogItemId -match '^\D+$')}
$duration = 0
$amountBilled = 0
$cost = 0
foreach ($item in $nonBacklogitems)
{
    Write-Output " $($item.BacklogItemId) has a duration of $($item.Duration) hours, Billed Amount of $($item.AmountBilled), Cost of $($item.Cost).";

    $duration += $item.Duration;
    $amountBilled += $item.AmountBilled;
    $cost += $item.Cost;

    if($item.BacklogItemId -eq [System.DBNull]::Value) 
    { 
        $featureTitle = "_MissingWorkpackage" 
        $cleanupHints += "There are $([math]::Round($item.duration, 2)) hours booked without Task. Check TC for unassigned task to get rid of '_MissingWorkpackage'."
    }
    else { $featureTitle = "_$($item.BacklogItemId)" }
    
    $row = [pscustomobject][ordered]@{ Feature=$Null; FeatureTitle=$featureTitle; Child=$Null; ChildTitle=$item.BacklogItemId; Duration=[math]::Round($item.duration, 2); AmountBilled=[math]::Round($item.AmountBilled, 2); }
    if($targetGroup -eq "internal")    { Add-Member -InputObject $row -NotePropertyMembers @{ Cost=[math]::Round($item.Cost, 2);} }

    $output += $row
}
Write-Output "Nonfunctional bookings have a duration of ${duration} hours, Billed Amount of ${amountBilled}, Cost of ${cost}.";
if($cleanupHints.Count -gt 0) 
{
    Write-Output "";
    Write-Warning "There are inconsistencies in the Report. Consider the following actions:";
    $cleanupHints | fl
}

# write output file
if($outputFile)
{
    # amment confidential note in case of internal
    if($targetGroup -eq "internal")
    {
        $outputFile = $outputFile.DirectoryName + [System.IO.Path]::DirectorySeparatorChar + $outputFile.BaseName + "_confidential" + $outputFile.Extension
    }
    
    Write-Output "Writing Results into ${outputFile}...";
    $output | Export-Csv -Path $outputFile -NoTypeInformation
}