Arcane.DevOps.psm1

<#
.SYNOPSIS
    Gets the root directory of the current Git repository.
.DESCRIPTION
    The Get-RepoRoot function retrieves the root directory of the current Git repository.
    This is useful for scripts that need to access files or directories within the repository.
.EXAMPLE
    Get-RepoRoot
    This example retrieves the root directory of the current Git repository.
#>

function Get-RepoRoot {
    $root = git rev-parse --show-toplevel
    return $root
}

<#
.SYNOPSIS
Loads environment variables from a .env file into the current PowerShell session.

.DESCRIPTION
The Get-EnvironmentVariables function reads key-value pairs from a .env file and sets them as environment variables in the current PowerShell session. This is useful for managing configuration settings and secrets in a centralized file.

.PARAMETER FilePath
The path to the .env file that contains the environment variables to load. If not specified, the function will look for a .env file in the current directory.

.EXAMPLE
Get-EnvironmentVariables -FilePath "/path/to/.env"
This example loads environment variables from the specified .env file.

.EXAMPLE
Get-EnvironmentVariables
This example loads environment variables from a .env file in the current directory.

.NOTES
Make sure the .env file is formatted with each environment variable on a new line in the format KEY=VALUE.
#>

function Get-EnvironmentVariables {
    $envFilePath = Join-Path (Get-RepoRoot)  ".env"
    if (Test-Path $envFilePath) {
        Get-Content $envFilePath | ForEach-Object {
            if ($_ -match "^\s*([^#][^=]+?)\s*=\s*(.+?)\s*$") {
                [System.Environment]::SetEnvironmentVariable($matches[1], $matches[2])
            }
        }
    }
}

<#
.SYNOPSIS
    Installs and updates a specific PowerShell module.
.DESCRIPTION
    Ensures that specified PowerShell module is installed and up-to-date.
.INPUTS
    None.
.OUTPUTS
    None.
.EXAMPLE
    Get-CurrentModule "Az"
#>

function Get-CurrentModule {
    Param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        $Module
    )

    # Get current version if any
    $version = (Get-Module -ListAvailable $Module) | `
        Sort-Object Version -Descending  | `
        Select-Object Version -First 1 | `
        Select-Object ModuleVersion -ExpandProperty Version

    # Get available version
    $galleryVersion = Find-Module -Name $Module | `
        Sort-Object Version -Descending | `
        Select-Object Version -First 1 | `
        Select-Object OnlineVersion -ExpandProperty Version

    if ($null -eq $version) {
        Write-Host "Installing module $Module..." -NoNewline
        Install-Module $Module -Force | Out-Null  -WarningAction Ignore
        Write-Host "Done!" -ForegroundColor Green
    }
    else {
        if ([version]$galleryVersion -gt [version]$version) {
            Write-Host "Updating module $Module..." -NoNewline
            Update-Module $Module -Force | Out-Null  -WarningAction Ignore
            Write-Host "Done!" -ForegroundColor Green
        }
    }
}

<#
.SYNOPSIS
Displays the help information for the script.

.DESCRIPTION
The Show-PullRequestHelp function provides detailed information on how to use the script, including available parameters and examples.

.EXAMPLE
Show-PullRequestHelp
This command displays the help information for the script.
#>

function Show-PullRequestHelp {
    Write-Output "Usage: .\New-ChangeSet.ps1 [--all] [--filter <regex>] [--help]"
    Write-Output ""
    Write-Output "Options:"
    Write-Output " -all Include 'Chores' and 'Other' sections in the output."
    Write-Output " -filter <regex> Filter out commits matching the given regular expression."
    Write-Output " -output [filename] Creates/updates the pull request to GitHub."
    Write-Output " -help Show this help message and exit."
}

<#
.SYNOPSIS
    Checks and sets Jira credentials in the environment variables.

.DESCRIPTION
    This function checks if the Jira email and token are set in the environment variables.
    If not, it prompts the user to enter the Jira email and optionally create a new Jira API token.
    The entered credentials are saved in a .env file and set as environment variables.

.PARAMETER None
    This function does not take any parameters.

.NOTES
    The function will prompt the user for input if the required environment variables are not set.
    It will also open a browser window to the Jira API token creation page if needed.

.EXAMPLE
    Confirm-JiraCredentials
    # This will check for Jira credentials and prompt the user to enter them if they are not already set.
#>

function Confirm-JiraCredentials {
    $envFilePath = ".env"
    $reloadEnv = $false

    if (-not $env:JIRA_EMAIL) {
        # Try to get the email from git config
        $gitEmail = git config user.email
        if ($gitEmail) {
            $jiraEmail = Read-Host "Enter your Jira email (Press Enter to use '$gitEmail')"
            if ($jiraEmail -eq "") {
                $jiraEmail = $gitEmail
            }
        }
        else {
            $jiraEmail = Read-Host "Enter your Jira email"
        }
        Add-Content -Path $envFilePath -Value "JIRA_EMAIL=$jiraEmail"
        [System.Environment]::SetEnvironmentVariable("JIRA_EMAIL", $jiraEmail)
        $reloadEnv = $true
    }

    if (-not $env:JIRA_TOKEN) {
        $createToken = Read-Host "Do you want to create a new Jira API token? (yes/no)"
        if ($createToken -eq "" -or $createToken.Substring(0, 1).ToLower() -eq "y") {
            Start-Process "https://id.atlassian.com/manage-profile/security/api-tokens"
        }

        # Mask the input for the Jira API token
        $secureJiraToken = Read-Host "Enter your Jira API token" -AsSecureString
        $jiraToken = ConvertFrom-SecureString $secureJiraToken -AsPlainText
        Add-Content -Path $envFilePath -Value "JIRA_TOKEN=$jiraToken"
        [System.Environment]::SetEnvironmentVariable("JIRA_TOKEN", $jiraToken)
        $reloadEnv = $true
    }

    if ($reloadEnv) {
        Get-EnvironmentVariables
    }
}

<#
.SYNOPSIS
Retrieves a pull request template.

.DESCRIPTION
The Get-Template function retrieves a pull request template.

.EXAMPLE
PS C:\> Get-Template
This example retrieves a template using the Get-Template function.
#>

function Get-Template {
    # Find the first pull_request_template.md file in the repository
    $template_file = Get-ChildItem -Recurse -Filter "pull_request_template.md" -Force | Select-Object -First 1

    # Check if the template file was found
    if (-not $template_file) {
        $template_content = "## What was changed?`n`n<!-- Add requirements -->`n`n `n`n## What was changed?`n<!-- Add changelog -->`n"
    }
    else {
        # Read the pull request template
        $template_content = Get-Content -Path $template_file.FullName -Raw
    }

    return $template_content
}

<#
.SYNOPSIS
Retrieves the issue identifier from the current branch name.

.DESCRIPTION
The Get-IssueIdentifier function extracts the issue identifier from the current branch name.

.EXAMPLE
PS C:\> Get-IssueIdentifier
This example retrieves the issue identifier from the current branch name.
#>

function Get-IssueIdentifier {
    if ($current_branch -match '(patch|feature)/([a-z0-9]+-[0-9]+)') {
        $issue_identifier = $matches[2].ToUpper()
        return $issue_identifier
    }
    return ""
}

<#
.SYNOPSIS
Retrieves the change set for a given repository.

.DESCRIPTION
The Get-ChangeSet function retrieves the change set for a specified repository.
It can be used to gather information about the changes made in the repository.

.PARAMETER Template
The pull request template, and existing content, to use.

.EXAMPLE
PS> Get-ChangeSet $template
#>

function Get-ChangeSet {

    Param (
        [string]$template
    )

    # Fetch the commit messages from the current branch that are not in the base branch and reverse the order
    $saves = git log "$base_branch..HEAD" --pretty=format:"%s" | ForEach-Object { $_ } | Sort-Object { $_ } -Descending

    # Initialize the changeset
    $changeset = ""

    # Initialize variables for each conventional commit type
    $docs_commits = ""
    $feat_commits = ""
    $fix_commits = ""
    $test_commits = ""
    $chore_commits = ""
    $ci_commits = ""
    $other_commits = ""

    # Filter and format the commits
    foreach ($save in $saves) {
        # Apply filter if specified
        if ($filter -ne "" -and $save -match $filter) {
            continue
        }

        # Extract the conventional commit type and the message
        if ($save -match '^(Merge.*|.*\(#\d+\))$') {
            continue
        }

        if ($save -match '^(feat|fix|docs|test|chore|ci)(\([^\)]+\))?:\s*(.*)$') {
            $save_type = $matches[1]
            $save_message = $matches[3]
            switch ($save_type) {
                "docs" { $docs_commits += "- $save_message`n" }
                "feat" { $feat_commits += "- $save_message`n" }
                "test" { $test_commits += "- $save_message`n" }
                "fix" { $fix_commits += "- $save_message`n" }
                "chore" { $chore_commits += "- $save_message`n" }
                default { $other_commits += "- $save_message`n" }
            }
        }
        else {
            if ($all) {
                $other_commits += "- $save`n"
            }
        }
    }

    # Format the changeset
    if ($feat_commits -ne "") {
        $changeset += "### Features:`n`n$feat_commits`n"
    }
    if ($fix_commits -ne "") {
        $changeset += "### Fixes:`n`n$fix_commits`n"
    }
    if ($test_commits -ne "") {
        $changeset += "### Tests:`n`n$test_commits`n"
    }
    if ($docs_commits -ne "") {
        $changeset += "### Documentation:`n`n$docs_commits`n"
    }
    if ($ci_commits -ne "") {
        $changeset += "### Continuous Integration:`n`n$ci_commits`n"
    }
    if ($all) {
        if ($chore_commits -ne "") {
            $changeset += "### Chores:`n`n$chore_commits`n"
        }
        if ($other_commits -ne "") {
            $changeset += "### Other:`n`n$other_commits`n"
        }
    }

    # Append updated content to end of the template
    if ($template -ne $null -and $template.IndexOf("<!-- Add changelog -->") -gt 0) {
        return $template.Replace("<!-- Add changelog -->", "$changeset")
    }

    return $template + $changeset
}

<#
.SYNOPSIS
    Retrieves and formats the summary for a pull request.

.DESCRIPTION
    The Get-Summary function retrieves the Jira issue details and formats the summary for a pull request.
    It uses the Jira issue identifier from the current branch name to fetch the issue details from Jira.
    The function then formats the description and summary based on the issue details and the provided template.

.PARAMETER template
    The template string to be used for formatting the summary.

.RETURNS
    The formatted summary string.

.EXAMPLE
    $template = Get-Template
    $summary = Get-Summary -template $template
    Write-Output $summary

.NOTES
    This function requires the JiraPS module and Jira credentials to be set in the environment variables.
#>

function Get-Summary {

    Param (
        [string]$template
    )

    # Tell the module what is the server's address
    Set-JiraConfigServer -Server "https://evinova.atlassian.net"

    # Configure authentication
    $password = ConvertTo-SecureString $env:JIRA_TOKEN -AsPlainText -Force
    $creds = New-Object System.Management.Automation.PSCredential ($env:JIRA_EMAIL, $password)
    New-JiraSession -Credential $creds -ErrorAction Stop | Out-Null

    # Get the remote issue
    $issue_identifier = Get-IssueIdentifier
    $Global:Title = "[$issue_identifier]"
    $issue = Get-JiraIssue -Issue $issue_identifier -ErrorAction SilentlyContinue
    if ($issue -eq $null) {
        Write-Warning "Issue $issue_identifier not found in Jira."
        return $template
    }
    
    # Format the description
    $summary = $($issue.Description)
    $summary = $summary -split "`n", 2 | Select-Object -First 1

    # Format title
    $Global:Title += " $($issue.Summary)"
    if ($Global:Title.Length -gt 50) {
        $Global:Title = $Global:Title.Substring(0, 47) + "..."
    }

    # Format summary
    $has_summary = $template.IndexOf("<!-- Add requirements -->") -gt 0
    $summary = ($has_summary) ? $template.Replace("<!-- Add requirements -->", "$summary") : $template + "`n" + $summary

    return $summary
}

<#
.SYNOPSIS
Sets the content of a PR in GitHub.

.DESCRIPTION
The Set-PullRequestRemoteContent function is used to set or update content on the remote GitHub server.

.EXAMPLE
Set-PullRequestRemoteContent
#>

function Set-PullRequestRemoteContent {

    if ($null -ne $output -and $output -ne "") {
        return
    }

    # Check if a PR already exists and get its URL
    $pr_url = ""
    try {
        $pr_url = gh pr view --json url --jq '.url'
    }
    catch {
        $pr_url = ""
    }
    
    # Create or update the PR
    if ($pr_url.Length -gt 0) {
        gh pr edit $pr_url --body "$content"
    }
    else {
        gh pr create --base "$base_branch" --head "$current_branch" --title "$Global:Title" --body "$content" --draft
    }

    # Open the PR in the browser
    gh pr view --web
}

<#
.SYNOPSIS
    Generates a pull request.
.DESCRIPTION
    Generates a pull request using the template with details of the Jira ticket reference and the changeset for the current branch.
.PARAMETER all
    Include 'Chores' and 'Other' sections in the output.
.PARAMETER filter
    Filter out commits matching the given regular expression.
.PARAMETER help
    Show this help message and exit.
.PARAMETER output
    The output file to save the pull request content.
.EXAMPLE
    .pr.ps1 -all --filter "feat"

#>

function New-PullRequest {
    Param (
        [switch]$all,
        [string]$filter,
        [switch]$help,
        [string]$output
    )

    $base_branch = git remote show origin | Select-String 'HEAD branch' | ForEach-Object { $_.ToString().Split()[-1] }
    $current_branch = git rev-parse --abbrev-ref HEAD

    # Load environment variables from .env file
    Get-EnvironmentVariables

    # Check if Jira credentials are set
    Confirm-JiraCredentials

    # Install Jira module
    Get-CurrentModule "JiraPS"

    if ($help) {
        Show-PullRequestHelp
        exit 0
    }

    # Load template and merge with summary and changeset
    $content = Get-Template
    $content = Get-Summary $content
    $content = Get-ChangeSet $content

    # Publish to GitHub
    Set-PullRequestRemoteContent

    # Output the content
    if ($output -ne $null -and $output -ne "") {
        $content | Out-File -FilePath $output
    }
}

<#
.SYNOPSIS
    Gets the comment-based help and converts to GitHub Flavored Markdown.

.PARAMETER Name
    A command name or module name to get comment-based help. For modules, include '.psm1' to generate help for all functions in module.

.EXAMPLE
    Get-HelpByMarkdown -Name New-PullRequest > .\New-PullRequest.md

.EXAMPLE
    Get-HelpByMarkdown -Name Arcane.DevOps.psm1 > .\Arcane.DevOps.md

.INPUTS
    System.String

.OUTPUTS
    System.String
#>

function Get-HelpByMarkdown {
    param (
        [Parameter(Mandatory = $True)]
        $Name
    )

    function EncodePartOfHtml {
        param (
            [string]
            $Value
        )

    ($Value -replace '<', '&lt;') -replace '>', '&gt;'
    }

    function GetCode {
        param (
            $Example
        )
        $codeAndRemarks = (($Example | Out-String) -replace ($Example.title), '').Trim() -split "`r`n"

        $code = New-Object "System.Collections.Generic.List[string]"
        for ($i = 0; $i -lt $codeAndRemarks.Length; $i++) {
            if ($codeAndRemarks[$i] -eq 'DESCRIPTION' -and $codeAndRemarks[$i + 1] -eq '-----------') {
                break
            }
            if (1 -le $i -and $i -le 2) {
                continue
            }
            $code.Add($codeAndRemarks[$i])
        }

        $code -join "`r`n"
    }

    function GetRemark {
        param (
            $Example
        )
        $codeAndRemarks = (($Example | Out-String) -replace ($Example.title), '').Trim() -split "`r`n"

        $isSkipped = $false
        $remark = New-Object "System.Collections.Generic.List[string]"
        for ($i = 0; $i -lt $codeAndRemarks.Length; $i++) {
            if (!$isSkipped -and $codeAndRemarks[$i - 2] -ne 'DESCRIPTION' -and $codeAndRemarks[$i - 1] -ne '-----------') {
                continue
            }
            $isSkipped = $true
            $remark.Add($codeAndRemarks[$i])
        }

        $remark -join "`r`n"
    }

    function Print-HelpLink {
        param (
            $Function
        )

        @"
- [$($Function.Name)](#$($Function.Name.ToLower()))
"@

    }

    function Print-Help {
        param (
            $Function
        )
        @"
## $($Function.Name)
### SYNOPSIS
$($Function.Synopsis)

### SYNTAX
``````powershell
$((($Function.syntax | Out-String) -replace "`r`n", "`r`n`r`n").Trim())
``````

### DESCRIPTION
$(($Function.description | Out-String).Trim())

### PARAMETERS
"@
 + $(foreach ($parameter in $Function.parameters.parameter) {
                @"

#### -$($parameter.name) &lt;$($parameter.type.name)&gt;
$(($parameter.description | Out-String).Trim())
``````
$(((($parameter | Out-String).Trim() -split "`r`n")[-5..-1] | % { $_.Trim() }) -join "`r`n")
``````

"@

            }) + @"

### INPUTS
$($Function.inputTypes.inputType.type.name)

### OUTPUTS
$($Function.returnValues.returnValue[0].type.name)

### NOTES
$(($Function.alertSet.alert | Out-String).Trim())

### EXAMPLES
"@
 + $(foreach ($example in $Function.examples.example) {
                @"

#### $(($example.title -replace '-*', '').Trim())
``````powershell
$(GetCode $example)
``````
$(GetRemark $example)

"@

            }) + @"

"@


    }

    try {
        if ($Host.UI.RawUI) {
            $rawUI = $Host.UI.RawUI
            $oldSize = $rawUI.BufferSize
            $typeName = $oldSize.GetType().FullName
            if ($IsWindows) {
                $newSize = New-Object $typeName (500, $oldSize.Height)
                $rawUI.BufferSize = $newSize
            }
        }

        if ($Name.ToLower().EndsWith('.psm1')) {
            # Print header
            @"
# $($Name.Substring(0, $Name.Length - 5))
The following functions are available in this module:

"@

            $modulePath = Join-Path -Path (Get-Location).Path -ChildPath $Name
            $tokens = $errors = $null
            $ast = [System.Management.Automation.Language.Parser]::ParseFile(
                $modulePath,
                [ref]$tokens,
                [ref]$errors)

            $functionDefinitions = $ast.FindAll({
                    param([System.Management.Automation.Language.Ast] $Ast)

                    $Ast -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
                    # Class methods have a FunctionDefinitionAst under them as well, but we don't want them.
            ($PSVersionTable.PSVersion.Major -lt 5 -or
                    $Ast.Parent -isnot [System.Management.Automation.Language.FunctionMemberAst])
                }, $true)

            $functionDefinitions | Sort-Object -Property Name | ForEach-Object {
                # Only top level functions
                try {
                    $topLevel = $functionDefinitions[0].Parent.ToString().Length -eq $_.Parent.ToString().Length 
                    if ($topLevel ) {
                        $Function = Get-Help $_.Name -Full
                        Print-HelpLink $Function
                    }
                }
                catch {
                }
            }

            $functionDefinitions | Sort-Object -Property Name | ForEach-Object {
                # Only top level functions
                try {
                    $topLevel = $functionDefinitions[0].Parent.ToString().Length -eq $_.Parent.ToString().Length 
                    if ($topLevel ) {
                        $Function = Get-Help $_.Name -Full
                        Print-Help $Function
                    }
                }
                catch {
                }
            }
        }
        else {
            $Function = Get-Help $Name -Full
            Print-Help $Function
        }
    }
    finally {
        if ($Host.UI.RawUI -and $IsWindows) {
            $rawUI = $Host.UI.RawUI
            $rawUI.BufferSize = $oldSize
        }
    }
}

<#
.SYNOPSIS
    Updates the manifest file.
.DESCRIPTION
    Updates the manifest file to expose all latest function versions.
.PARAMETER Path
    The path to the module manifest file. By default, ./Rpic.PowerShell.psm1.
.PARAMETER IncrementMajor
    Increments the major version number.
.PARAMETER IncrementMinor
    Increments the minor version number.
.PARAMETER IncrementBuild
    Increments the build version number.
.INPUTS
    None.
.OUTPUTS
    None.
.EXAMPLE
    PS> Update-RpicManifest -IncrementBuild
#>

function Update-PackageManifest {
    Param(
        [Parameter(Mandatory = $false)]
        $Path = "./Arcane.DevOps.psm1",
        [Switch]$IncrementMajor,
        [Switch]$IncrementMinor,
        [Switch]$IncrementBuild
    )

    Write-Host "Updating manifest..." -NoNewLine

    New-RpicModuleCheck "PSScriptAnalyzer"

    $module = Get-ChildItem *.psm1 | Select-Object -First 1
    Import-Module $module.Name -Force -ea SilentlyContinue
    $moduleName = $module.Name.Replace(".psm1", "")

    $functions = @()
    $tokens = $errors = $null
    $ast = [System.Management.Automation.Language.Parser]::ParseFile(
        $module.FullName,
        [ref]$tokens,
        [ref]$errors)

    $functionDefinitions = $ast.FindAll({
            param([System.Management.Automation.Language.Ast] $Ast)
            $Ast -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
                    ($PSVersionTable.PSVersion.Major -lt 5 -or
            $Ast.Parent -isnot [System.Management.Automation.Language.FunctionMemberAst])
        }, $true)

    $lines = 0;
    $functionDefinitions | Sort-Object -Property Name | ForEach-Object {
        # Only top level functions
        try {
            $topLevel = $functionDefinitions[0].Parent.ToString().Length -eq $_.Parent.ToString().Length 
            if ($topLevel ) {
                $functions += $_.Name
                $lines ++
            }
        }
        catch {
        }
    }

    try {
        Update-ModuleManifest -Path "./$($moduleName).psd1" -RootModule "$($moduleName).psd1" -ea SilentlyContinue | Out-Null
    }
    catch {
        # do nothing
    }

    try {
        Update-ModuleManifest -Path "./$($moduleName).psd1" -FunctionsToExport $functions -ea SilentlyContinue | Out-Null
    }
    catch {
        # do nothing
    }

    try {
        Update-ModuleManifest -Path "./$($moduleName).psd1" -CmdletsToExport $functions -ea SilentlyContinue | Out-Null
    }
    catch {
        # do nothing
    }
    
    Write-Host "Done!" -ForegroundColor Green 
    Write-Host "Updated the module manifest $($moduleName).psd1 with $($lines) functions."

    if ($IncrementMajor -or $IncrementMinor -or $IncrementBuild) {
        $current = Test-ModuleManifest -Path "./$($moduleName).psd1"
        $major = $current.Version.Major
        $minor = $current.Version.Minor
        $build = $current.Version.Build
        if ($IncrementMajor) {
            $major = $major + 1
            $minor = 0
            $build = 0
        }
        if ($IncrementMinor) {
            $minor = $minor + 1
            $build = 0
        }
        if ($IncrementBuild) {
            $build = $build + 1
        }
        $version = $major, $minor, $build -join "."
        Write-Host "Incrementing version to $version"
        Update-ModuleManifest -Path "./$($moduleName).psd1" -ModuleVersion $version
    }
}