PSGitHubStats.psm1

Function CreateAuthHeader([string]$canonicalizedString,[string]$storageAccount,[string]$storageKey)
{
    [string]$signature = [string]::Empty
    [byte[]]$bytes = [System.Convert]::FromBase64String($storageKey)
    [System.Security.Cryptography.HMACSHA256] $SHA256 = New-Object System.Security.Cryptography.HMACSHA256(,$bytes)
    [byte[]] $dataToSha256 = [System.Text.Encoding]::UTF8.GetBytes($canonicalizedString)
    $signature = [System.Convert]::ToBase64String($SHA256.ComputeHash($dataToSha256))
    "SharedKey $($storageAccount):$signature"
}

Function Get-PSDownloadStats()
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]  # Statistics is the proper noun here
    [CmdletBinding(DefaultParameterSetName="default")]
    param(
        [parameter(ParameterSetName="Default")]
        [parameter(ParameterSetName="AzureTable")]
        [string]$repo = "powershell/powershell",

        [parameter(ParameterSetName="Default")]
        [parameter(ParameterSetName="AzureTable")]
        [datetime]$publishedSinceDate = "2016-08-16",  # this is when we went public

        [parameter(ParameterSetName="Default",Mandatory=$true)]
        [parameter(ParameterSetName="AzureTable",Mandatory=$true)]
        [string]$accessToken,

        [parameter(ParameterSetName="AzureTable")]
        [switch]$publishToAzure,

        [parameter(ParameterSetName="AzureTable",Mandatory=$true)]
        [string]$storageUrl,

        [parameter(ParameterSetName="AzureTable",Mandatory=$true)]
        [string]$storageTable,

        [parameter(ParameterSetName="AzureTable",Mandatory=$true)]
        [string]$storageAccount,

        [parameter(ParameterSetName="AzureTable",Mandatory=$true)]
        [string]$storageKey
    )

    $query = "https://api.github.com/repos/$repo/releases"
    $headers = @{Authorization="token $accessToken"}

    $releases = Invoke-RestMethod -Uri $query -Headers $headers
    foreach ($release in $releases) {
        foreach ($asset in $release.assets) {
            if ($release.draft -eq $false -and [datetime]::Parse($release.published_at) -ge $publishedSinceDate) {
                [string]$os = "Unknown"
                switch ([System.IO.Path]::GetExtension($asset.name))
                {
                    ".pkg" { $os = "MacOS"}
                    ".deb" { $os = "Linux"}
                    ".rpm" { $os = "Linux"}
                    ".msi" { $os = "Windows"}
                    ".zip" { $os = "Windows"}
                }
                [string]$distro = "Unknown"
                if ($os -eq "MacOS") {
                    $distro = "MacOS"
                } elseif ($asset.name -match "win10") {
                    $distro = "Windows10"
                } elseif ($asset.name -match "win7") {
                    $distro = "Windows7"
                } elseif ($asset.name -match "win81") {
                    $distro = "Windows8"
                } elseif ($asset.name -match "centos") {
                    $distro = "CentOS"
                } elseif ($asset.name -match "ubuntu.*14") {
                    $distro = "Ubuntu14"
                } elseif ($asset.name -match "ubuntu.*16") {
                    $distro = "Ubuntu16"
                }
                $pkg = [PSCustomObject]@{PartitionKey=$release.tag_name;RowKey=[System.Guid]::NewGuid().ToString();Tag=$release.tag_name;Name=$asset.name;Count=$asset.download_count;Published=$release.published_at;OS=$os;Distro=$distro;Date=(get-date -f "yyyy-MM-dd")}
                $pkg.PSTypeNames.Insert(0,"PSGitHubStats.Package")
                if ($publishToAzure)
                {
                    $json = $pkg | ConvertTo-Json -Compress
                    $date = [datetime]::UtcNow.ToString("R", [System.Globalization.CultureInfo]::InvariantCulture)
                    [string] $canonicalizedResource = "/$storageAccount/$storageTable"
                    $contentType = "application/json"
                    [string] $stringToSign = "POST`n`n$contentType`n$date`n$canonicalizedResource"
                    $headers = @{"Prefer"="return-no-content";"Authorization"=(CreateAuthHeader -canonicalizedString $stringToSign -storageAccount $storageAccount -storageKey $storageKey);
                        "DataServiceVersion"="3.0;NetFx";"MaxDataServiceVersion"="3.0;NetFx";"Accept"="application/json;odata=nometadata";
                        "Accept-Charset"="UTF-8";"x-ms-version"="2013-08-15";"x-ms-date"=$date}
                    $null = Invoke-RestMethod -Uri $storageUrl -Headers $headers -Body $json -Method Post -ContentType $contentType
                }
                else 
                {
                    $pkg
                }
            }
        }
    }
}

$script:MsftMembers = @()
$script:AttemptedToGetMsftMembers = $false

Function Get-PSMicrosoftMembers()
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    param(
        [parameter(Mandatory=$true)]
        [string]$accessToken
    )
    
    if ($script:AttemptedToGetMsftMembers -eq $false) {
        $script:AttemptedToGetMsftMembers = $true
        $query = "https://api.github.com/teams/1513871/members"
        $headers = @{Authorization="token $accessToken"}

        while ($query -ne $null) {
            try {
                $output = Invoke-WebRequest $query -UseBasicParsing -Headers $headers
            } catch [System.Net.WebException] {
                # non-Msft won't have permission to query, so just treat everyone as Community
                Write-Warning "All contributors will be treated as community as you don't have access to query membership of the PowerShell org on GitHub"
                break
            }
            $query = $null
            foreach ($member in ($output | ConvertFrom-Json)) {
                $script:MsftMembers += $member.login
                Write-Verbose $member.login
            }
            if ($null -ne $output.Headers.Link) {
                $links = $output.Headers.Link.Split(",").Trim()
                foreach ($link in $links) {
                    if ($link -match "<(?<url>.*?)>;\srel=`"(?<rel>.*?)`"") {
                        if ($matches.rel -eq 'next') {
                            $query = $matches.url
                        }
                    }
                }
            }
        }
    }
    $script:MsftMembers
}

Function Get-PSGitHubStats()
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding(defaultParameterSetName="daterange")]
    param(
        [parameter(ParameterSetName="daterange")]
        [datetime]$startDate = (Get-Date).AddDays(-1),

        [parameter(ParameterSetName="daterange")]
        [datetime]$endDate = (Get-Date),

        [parameter(ParameterSetName="lastmonth")]
        [switch]$lastMonth,

        [string]$repo = "PowerShell/PowerShell",

        [ValidateSet("Issue","PR")]
        [string]$type = "Issue",

        [switch]$commentsOnly,

        [switch]$includeOwn,     # include comments from creator of PR or item

        [parameter(Mandatory=$true)]
        [string]$accessToken      
    )

    [hashtable]$users = @{}

    function IncrementUserCount ([string] $user) {
        if ($user -eq 'msftclas') {  # skip the Microsoft CLA bot
            return
        }
        if ($users.ContainsKey($user)) {
            $users[$user]++
        }
        else {
            $users.Add($user, [int]1)
        }
    }

    if ($lastMonth) {
        $startDate = Get-Date -Month ((Get-Date).Month-1) -Day 1
        $endDate = (Get-Date -Day 1).AddDays(-1)
    }

    Write-Verbose "Start date: $startDate"
    Write-Verbose "End date: $endDate"

    $ErrorActionPreference = "Stop"
   
    Add-Type -AssemblyName System.Net
    $repo = [System.Net.WebUtility]::UrlEncode($repo)
    $query = "https://api.github.com/search/issues?q=is:$type+created:$($startDate.ToString("yyyy-MM-dd"))..$($endDate.ToString("yyyy-MM-dd"))+repo:$repo"
    $headers = @{Authorization="token $accessToken"}

    while ($query -ne $null) {
        Write-Verbose "Query: $query"

        $output = Invoke-WebRequest $query -UseBasicParsing -Headers $headers
        $items = $output | ConvertFrom-Json
        $query = $null

        foreach ($item in $items.items) {
            if ($commentsOnly -and $item.comments -gt 0) {
                $Headers = @{Authorization="token $accessToken"}
                $comments = Invoke-WebRequest $item.comments_url -Headers $Headers -UseBasicParsing | ConvertFrom-Json
                foreach ($comment in $comments) {
                    if (!$includeOwn -and $comment.user.login -eq $item.user.login) {
                        continue
                    }
                    IncrementUserCount $comment.user.login
                }
            
                if ($type -eq "PR") {
                    $pullRequest = Invoke-WebRequest $item.pull_request.url -Headers $Headers -UseBasicParsing | ConvertFrom-Json
                    $reviewComments = Invoke-WebRequest $pullRequest.review_comments_url -Headers $Headers -UseBasicParsing | ConvertFrom-Json
                    foreach ($comment in $reviewComments) {
                        if (!$includeOwn -and $comment.user.login -eq $item.user.login) {
                            continue
                        }
                        IncrementUserCount $comment.user.login
                    }
                }
            }
            else {
                IncrementUserCount $item.user.login
            }
        }

        if ($null -ne $output.Headers.Link) {
            $links = $output.Headers.Link.Split(",").Trim()
            foreach ($link in $links) {
                if ($link -match "<(?<url>.*?)>;\srel=`"(?<rel>.*?)`"") {
                    if ($matches.rel -eq 'next') {
                        $query = $matches.url
                    }
                }
            }
        }
    }

    $MsftMembers = @(Get-PSMicrosoftMembers -accessToken $accessToken)

    $users.GetEnumerator() | ForEach-Object {$user = [pscustomobject]@{Name=$_.Name;Count=$_.Value;Org="Community"}; if ($MsftMembers.Contains($user.Name)){$user.Org="Microsoft"};$user} | 
        ForEach-Object {$_.pstypenames.insert(0,"PSGitHub.Statistic");$_} | Sort-Object -Property Org,Count -Descending
}

Function Get-PSGitHubReport()
{
    [CmdletBinding(DefaultParameterSetName="default")]
    param(
        [parameter(ParameterSetName="Default")]
        [parameter(ParameterSetName="AzureTable")]
        [datetime]$startDate = (Get-Date).AddDays(-7),   # default to last 7 days

        [parameter(ParameterSetName="Default")]
        [parameter(ParameterSetName="AzureTable")]
        [datetime]$endDate = (Get-Date),

        [parameter(ParameterSetName="Default")]
        [parameter(ParameterSetName="AzureTable")]
        [string[]]$repos = "PowerShell/PowerShell",

        [parameter(ParameterSetName="Default",Mandatory=$true)]
        [parameter(ParameterSetName="AzureTable",Mandatory=$true)]
        [string]$accessToken,

        [parameter(ParameterSetName="AzureTable")]
        [switch]$publishToAzure,

        [parameter(ParameterSetName="AzureTable",Mandatory=$true)]
        [string]$storageUrl,

        [parameter(ParameterSetName="AzureTable",Mandatory=$true)]
        [string]$storageTable,

        [parameter(ParameterSetName="AzureTable",Mandatory=$true)]
        [string]$storageAccount,

        [parameter(ParameterSetName="AzureTable",Mandatory=$true)]
        [string]$storageKey          
    )

    # can't use classes since Azure Function only supports PSv4 currently
    function New-Contributor()
    {
        # private function
        [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]   # only in memory
        param
        (
            [string] $Name, 
            [string] $endDate, 
            [string] $Org, 
            [string] $Repo
        )
        
        $contributor = [PSCustomObject]@{Name=$Name;endDate=$endDate;Org=$Org;PrCount=0;IssueCount=0;
            PrCommentCount=0;IssueCommentCount=0;Total=0;Repo=$Repo;PartitionKey=$name;RowKey=([System.Guid]::NewGuid().ToString())}
        $contributor.PSTypeNames.Insert(0,"Get-PSGitHubReport.Contributor")
        $contributor
    }

    $ErrorActionPreference = "Stop"

    Write-Verbose "Start date: $startDate"
    Write-Verbose "End date: $endDate"

    foreach ($repo in $repos)
    {
        [hashtable] $Contributors = @{}
        $ContributionTypes = @{
            PR=@{CommentsOnly=$false;Property="PrCount";Type="PR"};
            Issue=@{CommentsOnly=$false;Property="IssueCount";Type="Issue"};
            IssueComments=@{CommentsOnly=$true;Property="IssueCommentCount";Type="Issue"};
            PrComments=@{CommentsOnly=$true;Property="PrCommentCount";Type="PR"}
        }

        [int]$repoPercentComplete = 0
        foreach ($ContributionType in $ContributionTypes.Keys) {
            Write-Progress -Activity "Generating Report for $repo" -PercentComplete $repoPercentComplete -Id 1
            Write-Progress -Activity "Getting $ContributionType" -Id 2
            $Contribution = $ContributionTypes[$ContributionType]
            $results = Get-PSGitHubStats -startDate $startDate -endDate $endDate -accessToken $accessToken -repo $repo -type $Contribution.Type -commentsOnly:$Contribution.CommentsOnly
            foreach ($user in $results) {
                if (!$Contributors.Contains($user.Name)) {
                    $Contributors.Add($user.Name, (New-Contributor -Name $user.Name -EndDate $endDate.ToString("u") -Org $user.Org -Repo $repo))
                }
                $contributor = $Contributors[$user.Name]
                $contributor.($Contribution.Property) = $user.Count
            }
            Write-Progress -Activity "Getting $ContributionType" -Completed -Id 2
            $repoPercentComplete += 25
            Write-Progress -Activity "Getting PR Comments" -Completed -Id 1
        }
        Write-Progress -Activity "Generating Report for $repo" -PercentComplete 100 -Completed

        $Contributors.GetEnumerator() | ForEach-Object { $_.Value.Total = $_.Value.PrCount + $_.Value.IssueCount + $_.Value.PrCommentCount + $_.Value.IssueCommentCount}

        if ($publishToAzure)
        {
            $date = [datetime]::UtcNow.ToString("R", [System.Globalization.CultureInfo]::InvariantCulture)
            [string] $canonicalizedResource = "/$storageAccount/$storageTable"
            $contentType = "application/json"
            [string] $stringToSign = "POST`n`n$contentType`n$date`n$canonicalizedResource"
            foreach ($contributor in $contributors.values)
            {
                $json = $contributor | ConvertTo-Json -Compress
                Write-Verbose "JSON: $json"
                $headers = @{"Prefer"="return-no-content";"Authorization"=(CreateAuthHeader -canonicalizedString $stringToSign -storageAccount $storageAccount -storageKey $storageKey);
                    "DataServiceVersion"="3.0;NetFx";"MaxDataServiceVersion"="3.0;NetFx";"Accept"="application/json;odata=nometadata";
                    "Accept-Charset"="UTF-8";"x-ms-version"="2013-08-15";"x-ms-date"=$date}
                $null = Invoke-RestMethod -Uri $storageUrl -Headers $headers -Body $json -Method Post -ContentType $contentType
            }
        }
        else 
        {
            $Contributors.Values | Sort-Object -Property Org,Total -Descending
        }

        # sleep if more repos to avoid going over GitHub rate limit
        if ($repo -ne $repos[$repos.Length-1]) {
            Start-Sleep -seconds 61
        }
    }
}