statistics.ps1

<#
.SYNOPSIS
Get statistics on Bookings, UserStories, StoryPoints and Bugs.
 
.DESCRIPTION
Timeframe of statistic can be either the whole project, a given timerange (use from and to parameters) or an iteration (use iteration parameter).
 
Stories and Bugs are treated as planned when they are assigned to the iteration including 2 workdays after the start of the iteration
    having a status of 'New' or 'Active'. XXX: consider already resolved/closed between start and this date
For planned items, the Storypoints are counted - meaning for planned Bugs storypoints are counted, for Bugs which are added during the
iteration (unplanned) the StoryPoints are not considered.
 
Stories and Bugs are treated as done (status 'Resolved' or 'Closed') when they are assigend to the iteration at the enddate of the iteration.
 
.PARAMETER tcProjects
    The name of the Timecockpit projects to get the data for.
 
.PARAMETER devOpsOrganization
    The DevOps organization the project is in. If omited (which is the default), then only booking statistics are created.
 
.PARAMETER devOpsProject
    The name of the devops project to get the data for.
 
.PARAMETER iteration
    Name of iteration to get data for. If Iteration is passed, the from and to date is taken from the iteration.
    The following macro names can be used for referencing iterations relative: @previous, @current, @next
 
.PARAMETER from
    Optional Parameter to consider costs only from the given date on. If iteration is set, the date is taken from the iteration.
    If neither from nor iteration are set, no date-from filter is considered and all past records are taken.
 
.PARAMETER to
    Optional Parameter to consider costs only up to the given date. If iteration is set, the date is taken from the iteration.
    If neither from nor iteration are set, no date-to filter is considered and all past records are taken.
 
.PARAMETER tolerance
    Tolerance of how many workdays into the sprint added items are still considered as planed. Is 0 by default.
    The holiday calendar is used from TimeCockpit to determine what is a workday or not.
     
.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
    .\statistics.ps1 -devOps Rolsped -devOpsProject TruckerApp -iteration 2019.48-49
     
.EXAMPLE
    Uses new credentials for DevOps and stores those in the credential manager
     
    .\statistics.ps1 -devOps Rolsped -devOpsProject TruckerApp -iteration 2019.48-49 -devOpsCredential Get-Credential -useCredentialsManager $True
#>

[CmdletBinding(SupportsShouldProcess=$true)]
param(
   [string[]] $tcProjects,
   [string] $devOpsOrganization,
   [string] $devOpsProject,
   [string] $iteration,
   [DateTime] $from,
   [DateTime] $to,
   [int] $tolerance = 0,
   [PSCredential] $tcDBCredential,
   [PSCredential] $devOpsCredential,
   [Boolean] $useCredentialsManager = $True
)

function Get-PAT
{
    Param(
       [Parameter(Mandatory=$true)]
       [string] $devOpsOrganization,
       [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.${devOpsOrganization}" }
    }
    if(!$Credential) { $Credential = Get-Credential -Message "Please enter Credentials for ${devOpsOrganization} DevOps."; }
    if($UseCredentialsManager) 
    { 
        Write-Credential "DevOps.${devOpsOrganization}" -Credential $Credential; 
    }

    return $Credential;
}

function Get-ItemsByIds([int[]] $ids, [datetime] $asOf)
{
    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.State,Microsoft.VSTS.Scheduling.StoryPoints&asOf=$($asOf.ToString('yyyy-MM-dd HH:mm'))&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, [datetime] $asOf)
{
    $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 $asOf

    return $items
}

function Get-StoryPoints([System.Object] $items)
{
    $storyPoints = 0
    foreach($item in $items.value)
    {
        $storyPoints += $item.fields."Microsoft.VSTS.Scheduling.StoryPoints";
    }
    return $storyPoints
}

function Substract-List([int[]] $listToSubstractFrom, [int[]] $toSubstract)
{
    $list = [System.Collections.ArrayList]$listToSubstractFrom.Clone()
    foreach($id in $toSubstract)
    {
        $list.Remove($id)
    }
    return $list
}

# 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)."

# validate parameters
if($devOpsOrganization -and !$devOpsProject)
{
    throw "When a devOpsOrganization is configured, then also a devOpsProject needs to be set."
}
if(!$tcProjects -or $tcProjects.Count -eq 0)
{
    throw "tcProjects requires to be set to at least one project."
}

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

if($devOpsOrganization)
{
    $devOpsUrl = "https://dev.azure.com/${devOpsOrganization}"

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

    $iterationId = $Null;
    $iterationObj = $Null;

    # get iterations the fetch start and enddate
    if($iteration)
    {
        $callUrl = "${devOpsUrl}/${devOpsProject}/_apis/work/teamsettings/iterations?api-version=5.1"
        $response = Invoke-RestMethod -Uri $callUrl -Method Get -ContentType "application/json" -Headers @{Authorization=("Basic {0}" -f $base64AuthInfo)}

        $iterations = $response.value;

        $from = [DateTime]::MinValue;

        for($i = 0;$i -lt $iterations.Count; $i++)
        {
            $existingIteration = $iterations[$i];
            
            if($iteration[0] -eq "@" -and $existingIteration.attributes.timeframe -eq "current")
            {
                if($iteration -eq "@current")
                {
                    $iterationObj = $existingIteration;
                    break;
                }
                if($iteration -eq "@previous" -and $i -gt 0)
                {
                    $iterationObj = $iterations[$i - 1];
                    break;
                }
                if($iteration -eq "@next" -and $i -lt $iterations.Count)
                {
                    $iterationObj = $iterations[$i + 1];
                    break;
                }
            }
            if($existingIteration.name -eq $iteration) 
            {
                $iterationObj = $existingIteration;
                break;
            }
        }

        if(!$iterationObj) 
        {
            Write-Output "Iteration ${iteration} not found in project ${devOpsProject}.";
            Return; 
        }
        $from = ([datetime]$iterationObj.attributes.startDate);
        $to = ([datetime]$iterationObj.attributes.finishDate).AddDays(1).AddSeconds(-1);
        $iterationId  = $iterationObj.id
        $iteration = $iterationObj.name
    }
}
if(!$iteration)
{
    if(-not $from) 
    { 
        $from = [DateTime]::MinValue 
    }
    else
    {
        $from = $from.Date
    }
    
    if(-not $to) 
    {
        $to = [DateTime]::MaxValue
    }
    else 
    {
        # set time of to to end of day
        $to = $to.Date.AddDays(1).AddSeconds(-1)
    }
}

###################################################
#### Effort Statistics ############################
###################################################

$plannedEffort = @{}
if($iteration)
{
    # determine workdays in sprint iteration
    $workdaysInDuration = [math]::Round(($to - $from).TotalDays);
    for ($d=$from;$d -le $to;$d=$d.AddDays(1))
    {
        if ($d.DayOfWeek -match "Sunday|Saturday") { $workdaysInDuration--; }
    }

    # fetch capacity data of the given sprint
    $callUrl = "${devOpsUrl}/${devOpsProject}/_apis/work/teamsettings/iterations/${iterationId}/capacities?api-version=5.1"
    $response = Invoke-RestMethod -Uri $callUrl -Method Get -ContentType "application/json" -Headers @{Authorization=("Basic {0}" -f $base64AuthInfo)}
    foreach($line in $response.value)
    {
        $daysOff = 0
        $line.daysOff | Foreach-Object { $daysOff += (([datetime]$_.end).AddDays(1) - [datetime]$_.start).Days }
        $capacityPerDay = ($line.activities | Measure-Object -Property capacityPerDay -Sum).Sum;

        $plannedEffort[$line.teamMember.uniqueName] = [pscustomobject][ordered]@{ DaysOff=$daysOff; CapacityPerDay=$capacityPerDay; }
    }
}

# Query by Colleague

$query = @"
select
    Customer.APP_Code as 'Customer',
    Project.APP_Code as 'Project',
    UserDetail.APP_Firstname as 'Firstname',
    UserDetail.APP_Username as 'Username',
    sum(cast(DATEDIFF(minute, Timesheet.APP_BeginTime, Timesheet.APP_EndTime) as float)) / 60 as 'Effort'
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
    join edhrqnfxua.APP_UserDetail as UserDetail on UserDetail.APP_UserDetailUuid = Timesheet.Relation_APP_UserDetail
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'))'
group by Customer.APP_Code, Project.APP_Code, UserDetail.APP_Firstname, UserDetail.APP_Username, UserDetail.APP_UserDetailUuid
order by UserDetail.APP_Firstname
"@


$items = (Invoke-TCDBSqlCmd -Query $query)
$totalHours = $items | Measure-Object -property Effort -Sum;
$sumSprintCapacity = 0

Write-Output "## By Colleague ##" 
foreach($item in $items) 
{
    $capacity = $plannedEffort[$item.Username];
    $sprintCapacity = ($workdaysInDuration - $capacity.DaysOff) * $capacity.CapacityPerDay
    $sumSprintCapacity += $sprintCapacity
    if($sprintCapacity -gt 0)
    {
        Write-Output "$($item.Firstname) $($item.Effort.ToString('0.##'))h vs. $($sprintCapacity.ToString('0.##'))h ($([Math]::Round($item.Effort / $sprintCapacity * 100, 1))%)";
    }
    else
    {
        Write-Output "$($item.Firstname) $($item.Effort.ToString('0.##'))h ($([Math]::Round($item.Effort / $totalHours.Sum * 100, 1))%)";
    }
}
if($sprintCapacity -gt 0)
{
    Write-Output "Total $($totalHours.Sum.ToString('0.##'))h vs. $($sumSprintCapacity)h ($([Math]::Round($totalHours.Sum / $sumSprintCapacity * 100, 1))%)";
}
else
{
    Write-Output "Total $($totalHours.Sum.ToString('0.##'))h";
}
Write-Output "" 

# Query by type of work

$query = @"
    select
        Customer.APP_Code as 'CustomerCode',
        Project.APP_Code as 'ProjectCode',
        CASE
            WHEN (CHARINDEX('-', Task.APP_Code) > 0)
                THEN 'Functionality'
            WHEN ISNUMERIC(Task.APP_Code) = 1
                THEN 'Functionality'
                ELSE Task.APP_Code
            END AS 'TaskCode',
        ISNULL(sum(cast(DATEDIFF(minute, Timesheet.APP_BeginTime, Timesheet.APP_EndTime) as float)) / 60, 0) as 'Effort'
    from edhrqnfxua.APP_Timesheet as Timesheet
        join edhrqnfxua.APP_Task as Task on Task.APP_TaskUuid = Timesheet.Relation_APP_Task
        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
    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'))'
    group by Customer.APP_Code, Project.APP_Code,
        CASE
            WHEN (CHARINDEX('-', Task.APP_Code) > 0)
                THEN 'Functionality'
            WHEN ISNUMERIC(Task.APP_Code) = 1
                THEN 'Functionality'
                ELSE Task.APP_Code
            END
"@


$items = (Invoke-TCDBSqlCmd -Query $query)
$totalHours = $items | Measure-Object -property Effort -Sum;

Write-Output "## By Type of Work ##" 
foreach($item in $items) 
{
    Write-Output "$($item.TaskCode) $($item.Effort.ToString('0.##'))h ($([Math]::Round($item.Effort / $totalHours.Sum * 100, 1))%)";
}
Write-Output "Total $($totalHours.Sum.ToString('0.##'))h";
Write-Output "" 



###################################################
#### DevOps Stories and Bugs Statistics ###########
###################################################

if($iteration)
{
    $fromWithTolerance = $from.AddDays($tolerance)
    $toWithTolerance = $to.AddDays($tolerance)
    # get number of new and open userstories and storypoints at begin of sprint
    $startStories = Get-Items "Select [System.Id], [System.State] From WorkItems Where [System.WorkItemType] = 'User Story' and [System.State] IN ('New', 'Open') and [System.TeamProject] = '${devOpsProject}' AND [System.IterationPath] under '${devOpsProject}\${iteration}' ASOF '$($fromWithTolerance.ToString('yyyy-MM-dd HH:mm'))' " $fromWithTolerance
    $startStoryPoints = Get-StoryPoints $startStories

    # get number of new and open bugs and storypoints at begin of sprint
    $startBugs = Get-Items "Select [System.Id], [System.State] From WorkItems Where [System.WorkItemType] = 'Bug' and [System.State] IN ('New', 'Open') and [System.TeamProject] = '${devOpsProject}' AND [System.IterationPath] under '${devOpsProject}\${iteration}' ASOF '$($fromWithTolerance.ToString('yyyy-MM-dd HH:mm'))' " $fromWithTolerance
    $startBugStoryPoints = Get-StoryPoints $startBugs

    # get number of resolved and closed userstories and storypoints at end of sprint
    $endStories = Get-Items "Select [System.Id], [System.State] From WorkItems Where [System.WorkItemType] = 'User Story' and [System.State] IN ('Resolved', 'Closed') and [System.TeamProject] = '${devOpsProject}' AND [System.IterationPath] under '${devOpsProject}\${iteration}' ASOF '$($toWithTolerance.ToString('yyyy-MM-dd HH:mm'))' " $toWithTolerance
    $endStoryPoints = Get-StoryPoints $endStories

    # get number of resolved and closed bugs at end of sprint
    $endBugs = Get-Items "Select [System.Id], [System.State] From WorkItems Where [System.WorkItemType] = 'Bug' and [System.State] IN ('Resolved', 'Closed') and [System.TeamProject] = '${devOpsProject}' AND [System.IterationPath] under '${devOpsProject}\${iteration}' ASOF '$($toWithTolerance.ToString('yyyy-MM-dd HH:mm'))' " $toWithTolerance


    $startStoryIds = $startStories.value | Select-Object -ExpandProperty Id
    if($startStoryIds -eq $Null) { $startStoryIds = @() }
    $endStoryIds = $endStories.value | Select-Object -ExpandProperty Id
    if($endStoryIds -eq $Null) { $endStoryIds = @() }
    $startBugIds = $startBugs.value | Select-Object -ExpandProperty Id
    if($startBugIds -eq $Null) { $startBugIds = @() }
    $endBugIds = $endBugs.value | Select-Object -ExpandProperty Id
    if($endBugIds -eq $Null) { $endBugIds = @() }

    # whole iteration stable stories
    $wholeiterationStoryIds = $startStoryIds | ?{$endStoryIds -contains $_}
    $wholeiterationStories = Get-ItemsByIds $wholeiterationStoryIds $from
    $wholeiterationStoryPoints = Get-StoryPoints $wholeiterationStories

    # added stories and storypoints
    $addedStoryIds = Substract-List $endStoryIds $startStoryIds
    $addedItems = Get-ItemsByIds $addedStoryIds $to
    $addedStoryPoints = Get-StoryPoints $addedItems

    # added bugs
    $addedBugIds = Substract-List $endBugIds $startBugIds

    # removed stories and storypoints
    $removedStoryIds = Substract-List $startStoryIds $endStoryIds
    $removedItems = Get-ItemsByIds $removedStoryIds $from
    $removedStoryPoints = Get-StoryPoints $removedItems

    # removed bugs
    $removedBugIds = Substract-List $startBugIds $endBugIds

    # whole iteration stable bugs
    $wholeiterationBugIds = $startBugIds | ?{$endBugIds -contains $_}
    $wholeiterationBugs = Get-ItemsByIds $wholeiterationBugIds $from
    $wholeiterationBugStoryPoints = Get-StoryPoints $wholeiterationBugs

    Write-Output "## Summary ##" 
    Write-Output "In Sprint **${iteration}** ($($from.ToUniversalTime().ToString('dd.MM.yyyy')) - $($to.ToUniversalTime().ToString('dd.MM.yyyy'))) we **spent $([Math]::Round($totalHours.Sum / 8, 1))** PT to **implement $($endStories.Count) Stories** and **fix $($endBugs.Count) issues**."
    Write-Output "" 

    Write-Output "## Details ##" 
    Write-Output "Stories: $($startStories.Count) +$($addedStoryIds.Count) -$($removedStoryIds.Count) =$($endStories.Count)";
    Write-Output " of those where $($wholeiterationStories.Count) UserStories having ${wholeiterationStoryPoints} StoryPoints stable in whole iteration.";
    Write-Output "Bugs: $($startBugs.Count) +$($addedBugIds.Count) -$($removedBugIds.Count) =$($endBugIds.Count)";
    Write-Output " of those where $($wholeiterationBugs.Count) Bugs having ${wholeiterationBugStoryPoints} StoryPoints stable in whole iteration.";
    Write-Output "Storypoints: $($startStoryPoints + $startBugStoryPoints) +$($addedStoryPoints) -$($removedStoryPoints) =$($endStoryPoints)";
    Write-Output "" 

    Write-Output "## Velocity ##" 
    $velocityPlanned = $totalHours.Sum / ($wholeiterationStoryPoints + $wholeiterationBugStoryPoints)
    $velocity = $totalHours.Sum / $endStoryPoints
    Write-Output "A planed StoryPoint that got resolved or closed translates to an effort of $([Math]::Round($velocityPlanned, 1)) hours ($($totalHours.Sum) / $($wholeiterationStoryPoints + $wholeiterationBugStoryPoints))."
    Write-Output "A StoryPoint that got resolved or closed translates to an effort of $([Math]::Round($velocity, 1)) hours ($($totalHours.Sum) / ${endStoryPoints})."
}
#Write-Output "Started with $($startIds -join ",")"
#Write-Output "Added $($addedIds -join ",")"
#Write-Output "Removed $($removedIds -join ",")"
#Write-Output "Ended with $($endIds -join ",")"