Creating GitHub Extension PowerShell Tools
In the last newsletter, I introduced you to extensions in the gh.exe
command-line tool for managing GitHub. I showed you some PowerShell code I had written to wrap around the native command-line tool and left other projects to you. But, as is typical, I couldn't leave well enough alone. Plus, I was bothered with a limitation in my previous code. Today, I want to show you how I wrapped the native gh.exe
commands for working with extensions in PowerShell.
A Better Search
One thing that bothered me about my previous code was the way emojis were handled. Some descriptions of extensions contain emojis,
gh extension search gh-f --sort updated
This also works with native JSON output.
gh extension search gh-f --sort updated --json fullName,url,updatedAt,description
But as soon as I convert the JSON to a PowerShell object, the emojis are lost.
gh extension search gh-f --sort updated --json fullName,url,updatedAt,description | ConvertFrom-Json | select Fullname,description
There is something in the gh.exe
output that prevents the emoji from being properly rendered.
The gh.exe
tool is using the native GitHub APIs, so started reverse-engineering the gh.exe
expression to return the same results using the GitHub API.
$search = "fzf"
$tr = Invoke-RestMethod -Uri "https://api.github.com/search/repositories?q=topic:gh-extension+$search+in:description" -Headers @{ "Accept" = "application/vnd.github.v3+json" }
$tr.items | ForEach-Object {
[PSCustomObject]@{
name = $_.name
description = $_.description
url = $_.html_url
}
}
The search is a compound query looking for the search term in the repository description and the topic gh-extension
. The results are JSON by default, so I can convert them to PowerShell objects
This is better in my opinion. With this proof-of-concept, I wrote this function.
Function Find-GHExtension {
[CmdletBinding()]
[OutputType('ghExtension')]
Param(
[Parameter(Position = 0, Mandatory, HelpMessage = 'What topic are you searching for?')]
[ValidateNotNullOrEmpty()]
[string]$SearchTopic
)
$splat = @{
headers = @{ 'Accept' = 'application/vnd.github.v3+json' }
uri = "https://api.github.com/search/repositories?q=topic:gh-extension+$SearchTopic+in:description"
}
$response = Invoke-RestMethod @splat
If ($response.total_count -gt 0) {
$response.items | ForEach-Object {
[PSCustomObject]@{
PSTypeName = 'ghExtension'
FullName = $_.full_name
Name = $_.name
Created = $_.created_at
Updated = $_.updated_at
Description = $_.description.trim()
Url = $_.html_url.trim()
Stars = $_.stargazers_count
Owner = $_.owner.login
}
}
}
else {
Write-Warning "No gh extensions found for $SearchTopic"
}
}
> ⚠ Warning > The GitHub API is subject to rate limits which you might hit if you run this repeatedly in a short time frame.
I included additional properties in the output object to make it more useful. I also added a type name to the object so I can create a custom format file to display the results in a more readable format. I will skip that for now.
Now, I get much better results.
Installing Extensions
Because I had a search function, it only made sense to have an install function. This function requires PowerShell 7,
Function Install-GHExtension {
[CmdletBinding(SupportsShouldProcess)]
[OutputType('none')]
Param(
[Parameter(
Position = 0,
Mandatory,
ValueFromPipelineByPropertyName,
ValueFromPipeline,
HelpMessage = 'What extension are you installing?'
)]
[ValidateNotNullOrEmpty()]
[ValidatePattern(('^\w+/\S+$'),ErrorMessage='The name should be in the "[HOST/]OWNER/REPO" format, got {0}')]
[string]$FullName
)
Process {
#Add support for WhatIf
If ($PSCmdlet.ShouldProcess($FullName)) {
$response = gh extension install $FullName
} #whatIf
} #process
}
One advantage of using a PowerShell function is that I can add support for the -WhatIf
parameter. This allows me to see what would happen without actually installing the extension. I also configured pipeline support so I could run a command like this using Out-ConsoleGridView
as an object picker.
PS C:\> Find-GHExtension markdown | Select-Object fullname,Updated,Description | Out-ConsoleGridView | Install-GHExtension
Cloning into 'C:\Users\Jeff\AppData\Local\GitHub CLI\extensions\gh-markdown-preview'...
remote: Enumerating objects: 315, done.
remote: Counting objects: 100% (115/115), done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 315 (delta 79), reused 77 (delta 59), pack-reused 200 (from 1)
Receiving objects: 100% (315/315), 469.45 KiB | 2.95 MiB/s, done.
Resolving deltas: 100% (190/190), done.
Cloning into 'C:\Users\Jeff\AppData\Local\GitHub CLI\extensions\gh-render-markdown'...
remote: Enumerating objects: 18, done.
remote: Counting objects: 100% (18/18), done.
remote: Compressing objects: 100% (13/13), done.
remote: Total 18 (delta 1), reused 14 (delta 1), pack-reused 0 (from 0)
Receiving objects: 100% (18/18), 5.01 KiB | 1.25 MiB/s, done.
Resolving deltas: 100% (1/1), done.
Listing Extensions
I also needed to update the function I wrote to list installed extensions. The output had the same emoji issue. I needed to revise the function to use the GitHub API to get the extension detail.
Function Get-GHInstalledExtension {
[CmdletBinding()]
[OutputType('ghExtension')]
Param()
$data = gh extension list
if ($data) {
foreach ($item in $data) {
$split = $item.Split()
$fullName = $split[2]
#get details using the GitHub APOI
$splat = @{
headers = @{ 'Accept' = 'application/vnd.github.v3+json' }
uri = "https://api.github.com/repos/$fullName"
}
$detail = Invoke-RestMethod @splat
#Create a custom typed object
[PSCustomObject]@{
PSTypeName = 'ghExtension'
FullName = $fullName
Owner = $fullName.Split('/')[0]
Name = $split[1]
URL = $detail.html_url
Description = $detail.description.trim()
Updated = $detail.updated_at
Size = $detail.size
Stars = $detail.stargazers_count
Version = $split[3]
}
}
}
else {
Write-Warning 'No gh extensions installed.'
}
}
Now, I have even better output.
I should update the function to let the user specify an extension by name. I should also have a custom format file. I'll leave these exercises to you.
GitHub Dashboard
Before I wrap this up, I want to mention one extension I think I will use a lot, and that is the gh-dash
extension.
PS C:\> Get-ghInstalledExtension | where description -match dashboard
FullName : dlvhdr/gh-dash
Owner : dlvhdr
Name : dash
URL : https://github.com/dlvhdr/gh-dash
Description : A beautiful CLI dashboard for GitHub 🚀
Updated : 12/17/2024 6:12:23 AM
Size : 28092
Stars : 7303
Version : v4.7.3
This extension provides a dashboard of your GitHub pull requests and issues.
PS C:\> gh dash
The extension opens up a terminal user interface (TUI) to show you all of your pull requests. Press s
to toggle between pull requests and issues.
Press ?
to see the keyboard shortcuts. You can manage your pull requests and issues right from the terminal. Although, you can also easily open the item in your browser.
Even though you can use /
to jump to the filter box and edit the filter, that is too much work for me. I'm likely to run this when I am in a local project folder. When I run this extension, I want it to default to the current GitHub repository.
Fortunately, the extension uses a configuration file.
PS C:\> dir $home\.config\gh-dash\
Directory: C:\Users\Jeff\.config\gh-dash
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 12/16/2024 10:24 AM 1245 config.yml
You can edit the configuration to customize the dashboard. I'm going to use PowerShell to customize the filters.
Here is a PowerShell script that defines a function, assuming you've installed the extension.
#requires -version 5.1
# C:\Scripts\Open-GHDash.ps1
<#
this function relies an a gh.exe extension
https://github.com/dlvhdr/gh-dash
and assumes you have run the command once to create the default config.yml file.
If you customize the default configuration you might need to revise this
function.
#>
Try {
[void](Get-Command gh.exe -ErrorAction stop)
}
Catch {
Write-Warning 'This function requires the Github CLI tool gh.exe'
Return
}
if (-Not (gh extension list | Select-String gh-dash -quiet)) {
$msg = @"
This function requires the extension dlvhdr/gh-dash which you can install with this command:
gh extension install dlvhdr/gh-dash
Install the extension and try again.
"@
Write-Warning $msg
Return
}
Function Open-GHDashboard {
[cmdletbinding()]
[alias('ghdash')]
Param(
[Parameter(Position=0,HelpMessage='The name of the module to open in the dashboard')]
[ValidateNotNullOrEmpty()]
#Use the current directory name
[String]$ModuleName = (Get-Location | Split-Path -Leaf),
[Parameter(HelpMessage = "Specify the GitHub owner name.")]
[ValidateNotNullOrEmpty()]
[String]$GitHubOwner = "jdhitsolutions"
)
$tmpConfigPath = Join-Path -path ([System.IO.Path]::GetTempPath()) -ChildPath "tmpConfig.yml"
$repo = "repo:$GitHubOwner/$ModuleName"
#validate the repository has a remote github repository
if (git remote -v 2>$null) {
#Get the default config file
$yml = Get-Content -Path "$HOME\.config\gh-dash\config.yml" -Raw
[regex]$rx = "author:@me"
$m = $rx.match($yml).Value
$replace = "{0} {1}" -f $m,$repo
$yml = $yml -replace $m, $replace
[regex]$rx = "assignee:@me"
$m = $rx.match($yml).Value
$replace = "{0} {1}" -f $m,$repo
$yml = $yml -replace $m, $replace
#default to Issues
$yml = $yml -replace "view: prs","view: issues"
#create a temporary configuration file
$yml | Out-File -FilePath $tmpConfigPath
#Invoke the extension with the revised temporary configuration file
gh dash --config $tmpConfigPath
}
else {
Write-Warning 'This folder does not appear to have a GitHub remote repository.'
}
}
If I am not in a folder with a GitHub repository, I get a warning.
PS C:\> Open-GHDashboard
WARNING: This folder does not appear to have a GitHub remote repository.
Otherwise, the dashboard opens with the current repository already configured with issues as the default focus.
PS C:\Scripts\PSBluesky> Open-GHDashboard
Now I have a very useful tool that save me time and is even a little fun to use.
Summary
I hope you find an extension or two that will make your work easier. And wrapping native tools in PowerShell can make them even more useful or easier to use. I'd love to know what you think about all of this.