Skip to content

Instantly share code, notes, and snippets.

@fluffy-cakes
Last active August 21, 2023 20:57
Show Gist options
  • Save fluffy-cakes/d6cd86d734ce99837551a80e79c6543b to your computer and use it in GitHub Desktop.
Save fluffy-cakes/d6cd86d734ce99837551a80e79c6543b to your computer and use it in GitHub Desktop.
azdo_prStats
<#
.SYNOPSIS
An easy text-based view of how quickly PRs are being approved.
.DESCRIPTION
This script was born out of the need to help a client understand how quickly we are progressing with PR approvals.
It's a handy way to view how a particular repo, or all the repos within an Azure DevOps project is progressing for
PR approvals.
It will find the longest running PR and set it's text-based graphical view at 100% noted with the "-" for each 1%.
All other PRs will be compared against it and show how they fare in % approval reaction time. It will sort the
PRs via PR number, which gives representation of progress over time.
You can either scope it to the whole project by not defining a particular repo, or if you define one it will scope
the view to only that repo. A Days parameter is provided if you want to scope it to show within the las x-days, else
it will show by default the last 9999 days. The script will output the required values to a JSON file which can be
used in conjunction with PowerBI (great graphic stats are shown using this), else it will output a text file named
accordingly with the results.
.PARAMETER days
(Optional)
How many days to pull the stats from. By default it is 9999 days, else you can pass in your own value. Note; it will
count the days backwards from exactly the time you ran it. So at 5:00pm minus 2 days will show stats from 2 days ago
starting from 5:00pm. It won't start from the begining of the day (something to script in later, perhaps).
.PARAMETER includeOld
(Optional)
Our client had renamed some repos with "zzz_" when they were discontinued. This was included to show stats from these
repos, but can be easily changed to a value you desire in the code, or omitted.
.PARAMETER organization
The organization for which Azure DevOps stats will be pulled from.
.PARAMETER project
The project within the organization for which Azure DevOps stats will be pulled from.
.PARAMETER repoName
(Optional)
A particular repository for which Azure DevOps stats will be pulled from.
.PARAMETER token
The Azure DevOps Personal Access Token used to pull the stats. Only required "Code (read)".
#>
[CmdletBinding()]
param (
[int] $days = 9999,
[switch]$includeOld,
[ValidateNotNullOrEmpty()]
[string]$organization,
[ValidateNotNullOrEmpty()]
[string]$project,
[string]$repoName,
[ValidateNotNullOrEmpty()]
[string]$token
)
$base64token = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(":${token}"))
$headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
$headers.Add("Authorization", "Basic ${base64token}")
$headers.Add("Content-Type", "application/json")
$url = "https://dev.azure.com/${organization}/${project}/_apis/git/repositories?api-version=7.1-preview.1"
$response = Invoke-RestMethod -Uri $url -Method "GET" -Headers $headers
$allRepos = @()
$statuses = @(
"Active"
"Completed"
)
foreach($repo in ($response.value.name | Sort-Object)) {
if($repoName -and ($repo -ne $repoName)) { continue } # if passed in repo name to check, skip those that don't match
if((-not $includeOld) -and ($repo -match "^zzz_")) { continue } # if option to include old is not check, then skip those matching zzz_
$thisRepo = [PSObject]@{
"repo" = $repo
}
foreach($status in $statuses) {
$repoMultiArray = @()
$url = "https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repo}/pullrequests?searchCriteria.status=${status}&api-version=7.1-preview.1"
$response = Invoke-RestMethod -Uri $url -Method "GET" -Headers $headers
foreach($pr in ($response.value | Sort-Object -Property "pullRequestId")) {
Write-Host "${repo} $($pr.pullRequestId): $($status.ToUpper())"
if($pr.isDraft) {
Write-Host "`t^ `"isDraft`", skipping"
continue
}
if(($pr.closedDate -lt (Get-Date).AddDays(-${days})) -and ($status -eq "Completed")) {
continue
}
$prObject = [PSObject]@{
"repo" = $repo
"id" = $pr.pullRequestId
"created" = $pr.creationDate
}
switch($status) {
"Active" {
$prObject.Add("closed", $pr.closedDate)
$prObject.Add("hours" , ([math]::Round((New-TimeSpan -Start $pr.creationDate -End (Get-Date)).TotalHours)))
}
"Completed" {
$prObject.Add("closed", $pr.closedDate)
$prObject.Add("hours" , ([math]::Round((New-TimeSpan -Start $pr.creationDate -End $pr.closedDate).TotalHours)))
}
}
# Required reviewers
$url = "https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repo}/pullRequests/$($pr.pullRequestId)/reviewers?api-version=7.1-preview.1"
$response = Invoke-RestMethod -Uri $url -Method "GET" -Headers $headers
$requirement = $response.value | Where-Object { $_.isRequired -eq $true }
$reviewers = @()
foreach($reviewer in $requirement) {
$thisPerson = $reviewer.displayName.Replace("[${project}]\", "")
if($thisPerson -notin $reviewers) {
$reviewers += $thisPerson
}
}
$prObject | Add-Member -NotePropertyName "reviewers" -NotePropertyValue $reviewers
# Comment count per PR
$url = "https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repo}/pullRequests/$($pr.pullRequestId)/threads?api-version=7.1-preview.1"
$response = Invoke-RestMethod -Uri $url -Method "GET" -Headers $headers
$comments = $response.value | Where-Object { $_.comments[0].commentType -eq "text" }
$stats = @()
foreach($comment in $comments) {
foreach($subcomment in $comment.comments) {
if ($subcomment.isDeleted) { continue } # skip deleted comments
$user = $subcomment.author.displayName
if ($stats.Count -eq 0) {
$stats += ,@($user, 1)
} else {
foreach ($s in $stats) {
$statsValue = 0
if ($s[0] -eq $user) {
$s[1]++
break
} else {
$statsValue = 1
}
}
if ($statsValue -eq 1) {
$stats += ,@($user, 1)
$statsValue = 0
}
}
}
}
$statArray = @()
foreach($stat in $stats) {
$obj = [PSObject]@{
$stat[0] = $stat[1]
}
$statArray += $obj
}
$prObject | Add-Member -NotePropertyName "comments" -NotePropertyValue $statArray
$repoMultiArray += $prObject
}
$thisStatus = [PSObject]@{
"pr_count" = $repoMultiArray.Count
"pr_average" = ([math]::Round(($repoMultiArray.hours | Measure-Object -Average).Average))
"prs" = $repoMultiArray
}
$thisRepo | Add-Member -NotePropertyName "pr_${status}" -NotePropertyValue $thisStatus
}
$allRepos += $thisRepo
}
if($repoName) {
$projectFile = "${project}_${repoName}"
}
else {
$projectFile = "${project}_all"
}
##
# Output results into JSON file that can be used for PowerBI
##
$allRepos | ConvertTo-Json -Depth 100 | Out-File -FilePath "./${projectFile}.json" -Force
##
# Output results into text file
##
$fileName = "prTime_${projectFile}.txt"
Write-Output "$(Get-Date -Format "dddd yyyy/MM/dd HH:mm") $((Get-TimeZone).Id)" | Out-File $fileName
if($repoName) {
Write-Output "`n$($repoName.ToUpper())" | Out-File -Append $fileName
}
Write-Output "Check for the last ${days} days`n" | Out-File -Append $fileName
foreach($type in "pr_Active","pr_Completed") {
Write-Output "`n$($type.ToUpper())" | Out-File -Append $fileName
$max = ($allRepos.${type}.prs.hours | Measure-Object -Maximum).Maximum
foreach($pr in ($allRepos.${type}.prs | Sort-Object -Property "id")) {
if($pr.hours -ne 0.0) {
$percent = [math]::Round(($pr.hours/$max)*100, 2)
$percentString = "$("-"*$percent)"
}
else {
$percentString = ""
}
$arrayThis = @($pr.repo.PadRight(32, " "), $pr.id.ToString().PadRight(6, " "), $percentString, "$($pr.hours) hrs")
$output = $arrayThis -join " "
Write-Output $output | Out-File -Append $fileName
}
}
foreach($repo in $allRepos) {
Write-Output "`n$($repo.repo)" | Out-File -Append $fileName
Write-Output "`tActive Count : $($repo.pr_Active.pr_count)" | Out-File -Append $fileName
Write-Output "`tActive Avg Hrs : $($repo.pr_Active.pr_average)" | Out-File -Append $fileName
Write-Output "`t ~~~" | Out-File -Append $fileName
Write-Output "`tCompleted Count : $($repo.pr_Completed.pr_count)" | Out-File -Append $fileName
Write-Output "`tCompleted Avg Hrs: $($repo.pr_Completed.pr_average)`n" | Out-File -Append $fileName
}
Friday 2022/04/22 08:31 GMT Standard Time
THISREPO1
Check for the last 9999 days
PR_ACTIVE
thisRepo1 11618 ---------------------------------------------------------------------------------------------------- 549 hrs
thisRepo1 11759 ------------------------------------------------------------------------- 403 hrs
thisRepo1 14336 ------- 41 hrs
PR_COMPLETED
thisRepo1 10665 ------------------------------------------------- 166 hrs
thisRepo1 10954 ------ 19 hrs
thisRepo1 11164 - 3 hrs
thisRepo1 11240 - 3 hrs
thisRepo1 11252 0 hrs
thisRepo1 11255 0 hrs
thisRepo1 11399 ----- 18 hrs
thisRepo1 11440 1 hrs
thisRepo1 11447 ------- 24 hrs
thisRepo1 11521 0 hrs
thisRepo1 11602 ----- 17 hrs
thisRepo1 11604 ------------- 45 hrs
thisRepo1 11637 0 hrs
thisRepo1 11653 ---------------------------- 96 hrs
thisRepo1 11668 ----------------------------- 99 hrs
thisRepo1 11723 - 2 hrs
thisRepo1 11767 ---------------------------------------------------------------------------------------------------- 337 hrs
thisRepo1 11908 ------------------------------------------ 142 hrs
thisRepo1 12276 0 hrs
thisRepo1 12288 ------ 19 hrs
thisRepo1 12325 0 hrs
thisRepo1
Active Count : 3
Active Avg Hrs : 331
~~~
Completed Count : 21
Completed Avg Hrs: 47
@fluffy-cakes
Copy link
Author

Updated/fixed user comment stats count. Was only counting the first comment and not its threaded comments, too. Now it counts all that have not been deleted.

@fluffy-cakes
Copy link
Author

i needed to switch around the condition from "if Active or Completed was created in the last 14 days" to "i want ALL the Actives regardless, and ONLY Completed if it's been closed within the last x days"

@kylehillegass
Copy link

Do you have a similar script for Github?

@fluffy-cakes
Copy link
Author

Do you have a similar script for Github?

i do not

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment