listBookings.ps1
<#
.SYNOPSIS Returns timebookings enriched by information on DevOps Backlogitems .DESCRIPTION .PARAMETER devOps The DevOps the bookings are in .PARAMETER tcCustomer The code of the customer to filter bookings for. If not present, all customers will be returned .PARAMETER tcProject The code of the project to filter bookings for. Only valid if tcCustomer is given. If not present, all projects matching other criteria will be returned. .PARAMETER from Date and time from which on to return bookings. If not present, all past bookings will be returned. .PARAMETER to Date and time until which on to return bookings. If not present, bookings with no to-limit will be returned. .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 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 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 .\listBookings.ps1 -devOps Rolsped -outputFile bookings.csv -tcCustomer Rolsped -tcProject TruckerApp -from 2020-01-01 -to 2020-01-31 #> [CmdletBinding(SupportsShouldProcess=$true)] param( [Parameter(Mandatory=$true)] [ValidateSet('GuidNew','Rolsped')] [string] $devOps, [string] $tcCustomer, [string] $tcProject, [DateTime] $from, [DateTime] $to, [Parameter(Mandatory=$true)] [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 "," # TODO: can we collect the errors and print them as cleanuphints? $devOpsGetCall = "${devOpsUrl}/_apis/wit/workitems?ids=${idsList}&fields=System.Id,System.Title,System.WorkItemType,System.AreaPath,Microsoft.VSTS.Scheduling.StoryPoints&errorPolicy=omit&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 | Where-Object { $_.rel } | Select-Object -ExpandProperty Target | Select-Object -ExpandProperty Id if($ids -eq $Null) { return @(); } $items = Get-ItemsByIds $ids return $items } Function IIf($If, $IfTrue, $IfFalse) { If ($If) {If ($IfTrue -is "ScriptBlock") {&$IfTrue} Else {$IfTrue}} Else {If ($IfFalse -is "ScriptBlock") {&$IfFalse} Else {$IfFalse}} } # 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://guidnew.visualstudio.com" } 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))) ################################################### #### Fetch Bookings ############################### ################################################### $queryFilters = @() if($tcCustomer) { $queryFilters += "Customer.APP_Code IN ('${tcCustomer}')" } if($tcProject) { $queryFilters += "Project.APP_Code IN ('${tcProject}')" } $query = @" select UserDetail.APP_Lastname + ', ' + UserDetail.APP_Firstname as 'User', Customer.APP_Code as 'Customer', Project.APP_Code as 'Project', Task.APP_Code as 'Task', CASE WHEN CHARINDEX('-', Task.APP_Code) > 0 THEN RIGHT(Task.APP_Code, CHARINDEX('-', Task.APP_Code) - 1) ELSE IIF(isnumeric(Task.APP_Code) = 1, Task.APP_Code, NULL) END AS 'BacklogItemId', CASE WHEN CHARINDEX('-', Task.APP_Code) > 0 THEN LEFT(Task.APP_Code, CHARINDEX('-', Task.APP_Code) - 1) ELSE NULL END AS 'ParentBacklogItemId', Task.APP_Description as 'TaskDescription', Timesheet.APP_BeginTime as 'BeginTime', Timesheet.APP_EndTime as 'EndTime', Timesheet.APP_Description as 'Description', cast(DATEDIFF(minute, Timesheet.APP_BeginTime, Timesheet.APP_EndTime) as float) / 60 as 'Duration', 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', case when Project.APP_Billable = 0 or Task.APP_NoBilling = 1 or Timesheet.APP_NoBilling = 1 then 0 else 1 end as 'Billable', COALESCE(Timesheet.APP_HourlyRateBilled, 0) as 'Billed', Timesheet.USR_CostRate as 'Cost' 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 left join edhrqnfxua.APP_Task as Task on Task.APP_TaskUuid = Timesheet.Relation_APP_Task where $($queryFilters -join ' and ') and Timesheet.APP_BeginTime > '$($from.ToString('yyyy-MM-dd'))' and Timesheet.APP_EndTime <= '$($to.ToString('yyyy-MM-dd HH:mm'))' "@ 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."; # fetch items and parentitems from DevOps for value lookups $devOpsItemIds = $items | Where-Object { $_.BacklogItemId -ne [System.DBNull]::Value } | Select-Object -ExpandProperty "BacklogItemId" -Unique $devOpsParentItemIds = $items | Where-Object { $_.ParentBacklogItemId -ne [System.DBNull]::Value } | Select-Object -ExpandProperty "ParentBacklogItemId" -Unique $devOpsAllItemIds = $devOpsItemIds + $devOpsParentItemIds | Get-Unique $devOpsItems = Get-ItemsByIds $devOpsAllItemIds Write-Output "Fetched $($devOpsItems.Count) Backlogitems."; $output = @() foreach($item in $items) { $area = $storyId = $storyTitle = $featureId = $featureTitle = $Null if($item.BacklogItemId -ne [System.DBNull]::Value) { # resolve additional fields from DevOps Items $devOpsData = $devOpsItems.value | Where-Object { $_.id -eq $item.BacklogItemId } | Select-Object -ExpandProperty fields $backlogItemType = $devOpsData | Select-Object -ExpandProperty "System.WorkItemType" if($backlogItemType -eq "Task") { $parentDevOpsData = $devOpsItems.value | Where-Object { $_.id -eq $item.ParentBacklogItemId } | Select-Object -ExpandProperty fields $area = $parentDevOpsData | Select-Object -ExpandProperty "System.AreaPath" $storyTitle = $parentDevOpsData | Select-Object -ExpandProperty "System.Title" $storyId = $item.ParentBacklogItemId } if($backlogItemType -eq "User Story" -or $backlogItemType -eq "Bug" ) { $devOpsData = $devOpsItems.value | Where-Object { $_.id -eq $item.BacklogItemId } | Select-Object -ExpandProperty fields $area = $devOpsData | Select-Object -ExpandProperty "System.AreaPath" $storyTitle = $devOpsData | Select-Object -ExpandProperty "System.Title" $storyId = $item.BacklogItemId } } # fetch the feature $grantparent = Get-ItemsRelation "select [System].[Id], [System.Title] from WorkItemLinks where [Source].[System.Id] = '${storyId}' AND [System.Links.LinkType] = 'System.LinkTypes.Hierarchy-Reverse' and [Target].[System.WorkItemType] IN ('Feature')" # resolve additional Grandparent fields from fetched grandparent if($grantparent) { $featureId = $grantparent.value.id $featureTitle = $grantparent.value.fields."System.Title" } if($targetGroup -eq "internal" -or ($targetGroup -eq "external" -and $item.Billable -eq 1)) { Write-Output "Adding booking $($item.Task) $($item.BeginTime)" $row = [pscustomobject][ordered]@{ User = $item.User; Customer = $item.Customer; Project = $item.Project; Task = $item.Task; BacklogItemId = $item.BacklogItemId; Area = $area; StoryId = $storyId; StoryTitle = $storyTitle; FeatureId = $featureId; FeatureTitle = $featureTitle; TaskDescription = $item.TaskDescription; BeginTime = $item.BeginTime; EndTime = $item.EndTime; Description = $item.Description; Duration = $item.Duration; HourlyRate = $item.HourlyRate; Amount = $item.HourlyRate * $item.Duration; BilledAmount = $item.Billed * $item.Duration; } if($targetGroup -eq "internal") { Add-Member -InputObject $row -NotePropertyMembers @{ Billable = $item.Billable; } Add-Member -InputObject $row -NotePropertyMembers @{ Cost = $row.Duration * [math]::Round($item.Cost, 2); } } $output += $row } } # 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}..."; # write output file $output | Export-Csv -Path $outputFile -NoTypeInformation |