Codesmithy.psm1
|
function Add-GitHubMetadata { <# .SYNOPSIS Adds GitHub Linguist overrides to a repo's .gitattributes. .DESCRIPTION There is a lot of metadata that should be added to a good repo. This script simplifies adding much of that metadata. .FUNCTIONALITY Git and GitHub .LINK https://github.com/blog/2392-introducing-code-owners .LINK https://github.com/github/linguist#overrides .LINK https://github.com/blog/2111-issue-and-pull-request-templates .LINK https://help.github.com/articles/setting-guidelines-for-repository-contributors/ .LINK https://help.github.com/articles/adding-a-license-to-a-repository/ .LINK http://editorconfig.org/ .LINK https://github.com/brianary/Detextive/ .LINK Get-VSCodeSetting .LINK Set-VSCodeSetting .LINK Use-Command .EXAMPLE Add-GitHubMetadata -DefaultOwner arthurd@example.com -DefaultUsesTabs Sets up the CODEOWNERS file and assigns a user, and sets the indent default. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns','', Justification='These plural nouns work with groups.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter','', Justification='Parameters are not tracked accurately.')] [CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='Medium')][OutputType([void])] Param( <# Sets the code owner(s) by @username or email address to use when no more specific code owners are provided. By default, any authors within a standard deviation of the most commits will be included. #> [string[]] $DefaultOwner, <# Maps .gitattribute-style globbing syntax for file matching to @username or email address of owners of any matching files. #> [hashtable] $Owners = @{}, <# A list of .gitattribute-style globbing syntax matches for files that should be considered vendor code, for files not covered by the default behavior of the default Linguist vendor code file glob patterns: https://github.com/github/linguist/blob/master/lib/linguist/vendor.yml #> [string[]] $VendorCode = @('**/packages/**','**/lib/**'), <# A list of .gitattribute-style globbing syntax matches for files that should be considered documentation, for files not covered by the default behavior of the default Linguist documentation file glob patterns: https://github.com/github/linguist/blob/master/lib/linguist/documentation.yml #> [string[]] $DocumentationCode, <# A list of .gitattribute-style globbing syntax matches for files that should be considered generated code, for files not covered by the default behavior of the default Linguist generated code file glob patterns and contents matching: https://github.com/github/linguist/blob/master/lib/linguist/generated.rb #> [string[]] $GeneratedCode = @('"**/Service References/**"','"**/Web References/**"'), <# A Markdown string containing a template for creating issues. https://github.com/blog/2111-issue-and-pull-request-templates #> [string] $IssueTemplate, <# A Markdown string containing a template for creating pull requests. https://github.com/blog/2111-issue-and-pull-request-templates #> [string] $PullRequestTemplate, <# The file path or URL containing guidelines for contributors in Markdown format. https://help.github.com/articles/setting-guidelines-for-repository-contributors/ #> [string] $ContributingFile, <# The file path or URL containing open source licensing for contributors in Markdown format. https://help.github.com/articles/adding-a-license-to-a-repository/ #> [string] $LicenseFile, <# If no EditorConfig file exists, a simple default charset for text files in the repo. By default this is set to the system default, which is often terrible. #> [string] $DefaultCharset = $OutputEncoding.WebName, <# If no EditorConfig file exists, a simple default line endings value for text files in the repo. By default this is set to the system default, which is recommended. #> [string] $DefaultLineEndings = $(switch([Environment]::NewLine){"`n"{'lf'}"`r"{'cr'}default{'crlf'}}), <# If no EditorConfig file exists, a simple default number of characters to indent lines for spaces (soft tabs) and tab display (hard tabs) for text files in the repo. #> [int] $DefaultIndentSize = 4, <# If no EditorConfig file exists, this switch indicates a simple default for text files in the repo to use tabs. Otherwise, spaces will be used for indentation. #> [switch] $DefaultUsesTabs, <# If no EditorConfig file exists, this switch indicates a simple default for text files in the repo to preserve trailing spaces. Otherwise, trailing spaces will be trimmed. #> [switch] $DefaultKeepTrailingSpace, <# If no EditorConfig file exists, this switch indicates a simple default for text files in the repo not to add a final line ending at the end. Otherwise, a final line ending will be added automatically if it is missing. #> [switch] $DefaultNoFinalNewLine, # Indicates warnings about new content should be skipped. [switch] $NoWarnings, # Configure VSCode settings to recommend relevant extensions based on repo content. [Alias('Recommendations')][switch] $VsCodeExtensionRecommendations, <# Configure VSCode settings to disable the Prettier extension for Markdown files, since Prettier's formatting settings are not configurable, and some break Markdown for some interpreters (e.g. lower-casing footnote links is incompatible with GitHub Pages), and some formatting choices may not be desirable or may conflict with organizational standards. #> [Alias('DisablePrettierMarkdown','NoPrettierMarkdown')][switch] $VSCodeDisablePrettierForMarkdown, # Configure settings for Dev Containers, used for Codespaces. [Alias('Codespaces')][switch] $DevContainer, # Disables adding or updating the CODEOWNERS file. [switch] $NoOwners, # Do not prompt to append Linguist settings. [switch] $Force ) function Resolve-RepoPath { [CmdletBinding()] Param([Parameter(ValueFromPipelineByPropertyName=$true)][Alias('FullName')][string] $Path) Process { (Resolve-Path $Path -Relative) -replace '^.\\','' -replace '\\','/' } } function Test-KeepFile { [CmdletBinding(SupportsShouldProcess=$true)][OutputType([bool])] Param( [Parameter(Position=0)][string] $Filename, [switch] $Keep ) $exists = Test-Path $Filename -Type Leaf if($exists -and $Keep) { Write-Verbose "Keeping existing $Filename"; return $true } if(!$exists) { return $false } Write-Verbose "$Filename exists" return !$PSCmdlet.ShouldProcess($Filename,'overwrite') } function Copy-GitHubFile { [CmdletBinding()] Param( [Parameter(Position=0,Mandatory=$true)][string] $Filename, [Parameter(Position=1,Mandatory=$true)][Alias('Path','Url')][uri] $Source ) if(Test-KeepFile $Filename){return} if($Source.IsFile){Copy-Item $Source.LocalPath $Filename} else{Invoke-WebRequest $Source -OutFile $Filename} #TODO: authentication for private repos? } function Add-GitHubDirectory { [CmdletBinding()] Param() if(!(Test-Path .github -PathType Container)) {New-Item .github -Type Directory |Out-Null} } function Add-File { [CmdletBinding()] Param( [Parameter(Position=0,Mandatory=$true)][string] $Filename, [Parameter(Position=1,Mandatory=$true)][string] $Contents, [Parameter(Position=2)][ValidateSet('utf8','ASCII')][string] $Encoding = 'utf8', [switch] $Warn, [switch] $Keep, [switch] $Force ) if($Keep -and (Test-Path $Filename -Type Leaf)) { return } if(!$Contents){Write-Verbose "No contents to add to $Filename."; return } if(!$Force -and (Test-KeepFile $Filename)){ Write-Verbose "File $Filename exists!"; return } $Contents |Out-File $Filename -Encoding $Encoding git add -N $Filename |Out-Null if($Warn){ Write-Warning "The file $Filename has been added, be sure to review it and customize as needed." } Write-Verbose "Added $Filename" } function Add-Readme { [CmdletBinding()] Param( [string] $name = (git rev-parse --show-toplevel |Split-Path -Leaf), [switch] $NoWarnings ) if(Test-Path README.md -PathType Leaf){return} Add-File README.md @" $name $('='*$name.Length) TODO: Summarize purpose of repo contents here. Sections -------- TODO: Add sections for additional details, special instructions, prerequisites, &c. "@ -Warn:$(!$NoWarnings) } function Add-CodeOwners { [CmdletBinding()] Param( [string[]] $DefaultOwner, [hashtable] $Owners, [switch] $NoWarnings ) if(Test-KeepFile .github/CODEOWNERS -Keep:(!$DefaultOwner -and !$Owners)) { if('EFBBBF' -eq ('{0:X2}{1:X2}{2:X2}' -f (Get-Content .github/CODEOWNERS -AsByteStream -TotalCount 3))) { (Get-Content .github/CODEOWNERS -Raw).Trim() |Out-File .github/CODEOWNERS -Encoding utf8NoBOM } return } if(!$DefaultOwner) { Write-Verbose 'Determining default code owner(s).' $authors = git shortlog -nes HEAD | Select-String '^\s*(?<Commits>\d+)\s+(?<Name>\b[^>]+\b)\s+<(?<Email>[^>]+)>$' | ForEach-Object {[pscustomobject]@{ Commits = $_.Matches.Groups[1].Value Name = $_.Matches.Groups[2].Value Email = $_.Matches.Groups[3].Value }} $authors |Out-String |Write-Verbose [int] $max = ($authors |Measure-Object Commits -Maximum).Maximum [int] $oneSigmaFromTop = $max - ($authors.Commits |Measure-Object -StandardDeviation).StandardDeviation Write-Verbose "Authors with $oneSigmaFromTop or more commits will be included as default code owners." $DefaultOwner = $authors |Where-Object {[int] $_.Commits -ge $oneSigmaFromTop} |Select-Object -ExpandProperty Email Write-Verbose "Default code owners determined to be $DefaultOwner." } $Local:OFS = [Environment]::NewLine Add-File -Filename .github/CODEOWNERS -Contents @" # Code Owners file https://github.com/blog/2392-introducing-code-owners # .gitattributes selection syntax mapping to GitHub @usernames or email addresses. # default owner(s) * $DefaultOwner $(if($Owners){"$OFS# targeted owners"}) $($Owners.Keys |ForEach-Object {"$_ $($Owners[$_] -join ' ')"}) "@ -Encoding ASCII -Warn:$(!$NoWarnings) -Force } function Add-LinguistOverrides { [CmdletBinding(SupportsShouldProcess=$true)] Param( [string[]] $VendorCode, [string[]] $DocumentationCode, [string[]] $GeneratedCode, [switch] $Force ) if(!(Test-Path .gitattributes -PathType Leaf)) { Write-Verbose 'Creating .gitattributes file.' '','# Linguist overrides https://github.com/github/linguist#overrides' |Out-File .gitattributes ascii } else { if('EFBBBF' -eq ('{0:X2}{1:X2}{2:X2}' -f (Get-Content .gitattributes -AsByteStream -TotalCount 3))) { (Get-Content .github/CODEOWNERS -Raw).Trim() |Out-File .gitattributes -Encoding utf8NoBOM } if(Select-String '^# Linguist overrides' .gitattributes) { Select-String '^# Linguist overrides|\blinguist-\w+' .gitattributes |Out-String |Write-Verbose if(!$Force -and !$PSCmdlet.ShouldContinue('.gitattributes','append Linguist overrides')) { Write-Verbose 'The .gitattributes file already contains a "Linguist overrides" section.' return } } else { '','# Linguist overrides https://github.com/github/linguist#overrides' |Add-Content .gitattributes -Encoding UTF8 } } if($VendorCode) {$VendorCode |ForEach-Object {"$_ linguist-vendored"} |Add-Content .gitattributes -Encoding UTF8} if($DocumentationCode) {$DocumentationCode |ForEach-Object {"$_ linguist-documentation"} |Add-Content .gitattributes -Encoding UTF8} if($GeneratedCode) {$GeneratedCode |ForEach-Object {"$_ linguist-generated=true"} |Add-Content .gitattributes -Encoding UTF8} #TODO: linguist-language entries? git add -N .gitattributes |Out-Null Write-Verbose 'Added Linguist overrides section to .gitattributes.' Select-String '^# Linguist overrides|\blinguist-\w+' .gitattributes |Out-String |Write-Verbose } function Add-IssueTemplate { [CmdletBinding()] Param( [string] $IssueTemplate ) if(!$IssueTemplate){Write-Verbose 'No issue template.'; return} Add-File .github/ISSUE_TEMPLATE.md $IssueTemplate } function Add-PullRequestTemplate { [CmdletBinding()] Param( [string] $PullRequestTemplate ) if(!$PullRequestTemplate){Write-Verbose 'No pull request template.'; return} Add-File .github/PULL_REQUEST_TEMPLATE.md $PullRequestTemplate } function Add-ContributingGuidelines { [CmdletBinding()] Param( [string] $ContributingFile ) if(!$ContributingFile){Write-Verbose 'No contributing file.'; return} Copy-GitHubFile .github/CONTRIBUTING.md $ContributingFile } function Add-License { [CmdletBinding()] Param( [string] $LicenseFile ) if(!$LicenseFile){Write-Verbose 'No license.'; return} Copy-GitHubFile LICENSE.md $LicenseFile } function Add-EditorConfig { [CmdletBinding()] Param( [string] $DefaultCharset, [string] $DefaultLineEndings, [int] $DefaultIndentSize, [switch] $DefaultUsesTabs, [switch] $DefaultKeepTrailingSpace, [switch] $DefaultNoFinalNewLine, [switch] $NoWarnings ) Add-File .editorconfig @" # EditorConfig is awesome: http://EditorConfig.org # last word for the project root = true # defaults [*] indent_style = $(if($DefaultUsesTabs){'tab'}else{'space'}) indent_size = $DefaultIndentSize tab_width = $DefaultIndentSize end_of_line = $DefaultLineEndings charset = $DefaultCharset trim_trailing_whitespace = $(if($DefaultKeepTrailingSpace){'false'}else{'true'}) insert_final_newline = $(if($DefaultNoFinalNewLine){'false'}else{'true'}) # git [{.gitattributes,CODEOWNERS}] charset = utf-8 # CSS # https://www.w3.org/International/questions/qa-utf8-bom.en#bytheway [*.css] charset = utf-8 "@ -Warn:$(!$NoWarnings) -Keep } function Add-VsCodeExtensionRecommendations { [CmdletBinding()] Param() if(!(Test-Path .vscode -PathType Container)) {New-Item .vscode -Type Directory |Out-Null} $recommendations = New-Object Collections.Generic.HashSet[string] [string[]] $previous = Get-VSCodeSetting /recommendations -Workspace if($previous) {$previous |ForEach-Object {[void]$recommendations.Add($_)}} if((Test-Path .github -Type Container) -and (Test-Path .github/workflows -Type Container) -and (Get-ChildItem .github/workflows -Filter *.yml |Select-Object -First 1)) { [void]$recommendations.Add('GitHub.vscode-github-actions') } [void]$recommendations.Add('yzhang.markdown-all-in-one') [void]$recommendations.Add('EditorConfig.EditorConfig') if(Get-ChildItem -Recurse -Filter *.md |Select-String '```mermaid' |Select-Object -First 1) { [void]$recommendations.Add('bierner.markdown-mermaid') [void]$recommendations.Add('bpruitt-goddard.mermaid-markdown-syntax-highlighting') } if(Get-ChildItem -Recurse -Filter *.adoc |Select-Object -First 1) { [void]$recommendations.Add('asciidoctor.asciidoctor-vscode') } if(Get-ChildItem -Recurse -Filter *.http |Select-Object -First 1) { [void]$recommendations.Add('humao.rest-client') } if(Get-ChildItem -Recurse -Filter *.ps1 |Select-Object -First 1) { [void]$recommendations.Add('ms-vscode.powershell') } if(Get-ChildItem -Recurse -Filter *.sql |Select-Object -First 1) { [void]$recommendations.Add('ms-mssql.mssql') } Set-VSCodeSetting /recommendations $recommendations -Workspace } function Add-DevContainerSettings { [CmdletBinding()] Param() if(Test-Path .devcontainer/devcontainer.json -Type Leaf) { ${devcontainer.json} = '.devcontainer/devcontainer.json' $settings = Get-Content ${devcontainer.json} |ConvertFrom-Json } elseif(Test-Path .devcontainer.json -Type Leaf) { ${devcontainer.json} = '.devcontainer.json' $settings = Get-Content ${devcontainer.json} |ConvertFrom-Json } else { ${devcontainer.json} = '.devcontainer.json' $settings = [pscustomobject]@{ customizations = [pscustomobject]@{ vscode = [pscustomobject]@{ settings=[pscustomobject]@{} extensions=@() } } } } if(!$settings.PSObject.Properties.Match('customizations').Count) { $settings |Add-Member -NotePropertyName customizations -NotePropertyValue ([pscustomobject]@{ vscode = @{ settings=[pscustomobject]@{} extensions=@() } }) } elseif(!$settings.customizations.PSObject.Properties.Match('vscode').Count) { $settings.customizations |Add-Member -NotePropertyName vscode -NotePropertyValue ([pscustomobject]@{ settings=[pscustomobject]@{} extensions=@() }) } elseif(!$settings.customizations.vscode.PSObject.Properties.Match('extensions').Count) { $settings.customizations.vscode |Add-Member -NotePropertyName extensions -NotePropertyValue @() } $extensions = $settings.customizations.vscode.extensions -isnot [array] ? $settings.customizations.vscode.extensions : @() if(Get-ChildItem .github/workflows -Filter *.yml |Select-Object -First 1) { $extensions += 'GitHub.vscode-github-actions' } if(Get-ChildItem -Recurse -Filter *.md |Select-Object -First 1) { $extensions += 'DavidAnson.vscode-markdownlint' } if(Get-ChildItem -Recurse -Filter *.md |Select-String '```mermaid' |Select-Object -First 1) { $extensions += 'bierner.markdown-mermaid' $extensions += 'bpruitt-goddard.mermaid-markdown-syntax-highlighting' } if(Get-ChildItem -Recurse -Filter *.adoc |Select-Object -First 1) { $extensions += 'asciidoctor.asciidoctor-vscode' } $settings.customizations.vscode.extensions = @($extensions |Select-Object -Unique) $settings |ConvertTo-Json -Depth 5 |Out-File ${devcontainer.json} utf8 } function Disable-VsCodePrettier { [CmdletBinding()] Param() if(!(Test-Path .vscode -PathType Container)) {New-Item .vscode -Type Directory |Out-Null} Set-VSCodeSetting /prettier.disableLanguages @('markdown') -Workspace Set-VSCodeSetting '/[markdown]' @{ 'editor.defaultFormatter' = 'yzhang.markdown-all-in-one' } -Workspace } function Add-Metadata { [CmdletBinding()] Param( [string[]] $DefaultOwner, [hashtable] $Owners, [string[]] $VendorCode, [string[]] $DocumentationCode, [string[]] $GeneratedCode, [string] $IssueTemplate, [string] $PullRequestTemplate, [string] $ContributingFile, [string] $LicenseFile, [string] $DefaultCharset, [string] $DefaultLineEndings, [int] $DefaultIndentSize, [switch] $DefaultUsesTabs, [switch] $DefaultKeepTrailingSpace, [switch] $DefaultNoFinalNewLine, [switch] $NoWarnings, [switch] $Force ) if(!(Get-Command git -Type Application -ErrorAction Ignore)) {throw "Git is required to be installed!"} Push-Location $(git rev-parse --show-toplevel) Add-GitHubDirectory Add-Readme -NoWarnings:$NoWarnings if(!$NoOwners) {Add-CodeOwners -DefaultOwner $DefaultOwner -Owners $Owners -NoWarnings:$NoWarnings} Add-LinguistOverrides -VendorCode $VendorCode -DocumentationCode $DocumentationCode ` -GeneratedCode $GeneratedCode -Force:$Force Add-IssueTemplate -IssueTemplate $IssueTemplate Add-PullRequestTemplate -PullRequestTemplate $PullRequestTemplate Add-ContributingGuidelines -ContributingFile $ContributingFile Add-License -LicenseFile $LicenseFile Add-EditorConfig -DefaultCharset $DefaultCharset -DefaultLineEndings $DefaultLineEndings ` -DefaultIndentSize $DefaultIndentSize -DefaultUsesTabs:$DefaultUsesTabs ` -DefaultKeepTrailingSpace:$DefaultKeepTrailingSpace -DefaultNoFinalNewLine:$DefaultNoFinalNewLine ` -NoWarnings:$NoWarnings if($VsCodeExtensionRecommendations) {Add-VsCodeExtensionRecommendations} if($VSCodeDisablePrettierForMarkdown) {Disable-VsCodePrettier} if($DevContainer) {Add-DevContainerSettings} Pop-Location } Add-Metadata -DefaultOwner $DefaultOwner -Owners $Owners -VendorCode $VendorCode ` -DocumentationCode $DocumentationCode -GeneratedCode $GeneratedCode -IssueTemplate $IssueTemplate ` -PullRequestTemplate $PullRequestTemplate -ContributingFile $ContributingFile -LicenseFile $LicenseFile ` -DefaultCharset $DefaultCharset -DefaultLineEndings $DefaultLineEndings -DefaultIndentSize $DefaultIndentSize ` -DefaultUsesTabs:$DefaultUsesTabs -DefaultKeepTrailingSpace:$DefaultKeepTrailingSpace ` -DefaultNoFinalNewLine:$DefaultNoFinalNewLine -NoWarnings:$NoWarnings -Force:$Force } function Add-NotebookCell { <# .SYNOPSIS When run within a Polyglot Notebook, appends a cell to it. .PARAMETER Language The cell language to use. .FUNCTIONALITY Notebooks .INPUTS Any object that can be converted to a string and used as cell content. .EXAMPLE "flowchart LR`nA --> B" |Add-NotebookCell mermaid Appends a cell with a Mermaid chart. #> [CmdletBinding()] Param( # The cell content. [Parameter(Mandatory=$true,ValueFromPipeline=$true)][psobject] $InputObject ) DynamicParam { try {[Kernel]::Root.ChildKernels.Name |Add-DynamicParam.ps1 Language string} catch {'csharp','fsharp','html','http','javascript','kql','mermaid','pwsh','sql','value','vscode' |Add-DynamicParam.ps1 Language string} $DynamicParams } End { if(!$PSBoundParameters.ContainsKey('Language')) { [Kernel]::Root.SendAsync((New-Object SendEditableCode 'vscode',($input |Out-String))) |Out-Null } else { [Kernel]::Root.SendAsync((New-Object SendEditableCode $PSBoundParameters['Language'],($input |Out-String))) |Out-Null } } } function Add-VsCodeDatabaseConnection { <# .SYNOPSIS Adds a VS Code MSSQL database connection to the repo. .DESCRIPTION The VSCode MSSQL extension can use saved database connections to connect to for queries, and this allows adding those to the VSCode settings in the current git repo. .INPUTS Any object with these properties, used to construct a database connection entry: * ProfileName or Name * ServerInstance or Server or DataSource * Database or InitialCatalog * UserName or UID .FUNCTIONALITY VSCode .LINK https://marketplace.visualstudio.com/items?itemName=ms-mssql.mssql .LINK Get-VSCodeSetting .LINK Set-VSCodeSetting .EXAMPLE Add-VsCodeDatabaseConnection ConnectionName ServerName\instance DatabaseName Adds an MSSQL extension trusted connection named ConnectionName that connects to the server ServerName\instance and database DatabaseName. #> [CmdletBinding()][OutputType([void])] Param( # The name of the connection. [Parameter(Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true)] [Alias('Name')][string] $ProfileName, # The name of a server (and optional instance) to connect and use for the query. [Parameter(Position=1,Mandatory=$true,ValueFromPipelineByPropertyName=$true)] [Alias('Server','DataSource')][string] $ServerInstance, # The the database to connect to on the server. [Parameter(Position=2,Mandatory=$true,ValueFromPipelineByPropertyName=$true)] [Alias('InitialCatalog')][string] $Database, <# The username to connect with. No password will be stored. If no username is given, a trusted connection will be created. #> [Parameter(Position=3,ValueFromPipelineByPropertyName=$true)] [Alias('UID')][string] $UserName, # Overwrite an existing profile with the same name. [switch] $Force ) Begin { [psobject[]]$connections = Get-VSCodeSetting /mssql.connections -Workspace if(!$connections) {[psobject[]]$connections = @()} } Process { if($connections |Where-Object profileName -eq $ProfileName) { if($Force) {$connections = $connections |Where-Object profileName -ne $ProfileName} else {Write-Verbose "Connection '$ProfileName' already exists"; return} } $connections += if($UserName) {[pscustomobject]@{ server = $ServerInstance database = $Database authenticationType = 'SqlLogin' profileName = $ProfileName password = '' user = $UserName savePassword = $false }} else {[pscustomobject]@{ server = $ServerInstance database = $Database authenticationType = 'Integrated' profileName = $ProfileName password = '' }} } End { $connections |ConvertTo-Json -Compress |Write-Verbose Set-VSCodeSetting /mssql.connections $connections -Workspace } } function Copy-GitHubLabels { <# .SYNOPSIS Copies configured issue labels from one repo to another. .FUNCTIONALITY Git and GitHub .INPUTS An object with these properties: * owner or DestinationOwnerName (optional) * name or DestinationRepositoryName .LINK Get-GitHubLabel .LINK New-GitHubLabel .LINK Set-GitHubLabel .LINK Remove-GitHubLabel .EXAMPLE Copy-GitHubLabels -OwnerName brianary -RepositoryName scripts -DestinationRepositoryName webcoder Inserts new labels from the brianary/scripts repo to the brianary/webcoder repo, and also updates attributes like description and color from matching labels in the source. #> [CmdletBinding()] Param( # The source repository's owner name. [Parameter(Position=0,Mandatory=$true)][string] $OwnerName, # The source repository name. [Parameter(Position=1,Mandatory=$true)][string] $RepositoryName, # The destination repository's owner name. [Parameter(ValueFromPipelineByPropertyName=$true)][Alias('owner')][string] $DestinationOwnerName = $OwnerName, # The destination repository name. [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)][Alias('name')][string] $DestinationRepositoryName, <# Determines the copy behavior: * AddNew: Insert new labels from the source. * AddAndUpdate: Insert new labels from the source, and also overwrite attributes from matching labels in the source. * ReplaceAll: Insert new labels from the source, overwrite attributes from matching labels in the source, and delete any labels that don't exist in the source. #> [ValidateSet('AddAndUpdate','AddNew','ReplaceAll')][string] $Mode = 'AddAndUpdate' ) Begin { [pscustomobject[]] $source = Get-GitHubLabel -OwnerName $OwnerName -RepositoryName $RepositoryName } Process { $destination = @{ OwnerName = $DestinationOwnerName; RepositoryName = $DestinationRepositoryName } [pscustomobject[]] $labels = Get-GitHubLabel @destination $source |Where-Object LabelName -NotIn $labels.LabelName | ForEach-Object {New-GitHubLabel @destination -Label $_.name -Color $_.color -Description $_.description} if($Mode -ne 'AddNew') { $source |Where-Object LabelName -In $labels.LabelName | ForEach-Object {Set-GitHubLabel @destination -Label $_.name -Color $_.color -Description $_.description} if($Mode -eq 'ReplaceAll') { $labels |Where-Object LabelName -NotIn $source.LabelName |Remove-GitHubLabel @destination } } } } function Export-OpenApiSchema { <# .SYNOPSIS Extracts a JSON schema from an OpenAPI definition. .OUTPUTS System.String of the extracted JSON schema. .FUNCTIONALITY Json .LINK https://www.openapis.org/ .LINK Export-Json .LINK Set-Json .EXAMPLE Export-OpenApiSchema api.json Returns the schema of the 200 response of any defined endpoint is returned. .EXAMPLE Export-OpenApiSchema api.json POST /hello -RequestSchema Returns the schema of the request body of the POST /hello endpoint. #> [CmdletBinding(DefaultParameterSetName='ResponseStatus')][OutputType([string])] Param( # The path to the OpenAPI JSON file. [Parameter(Position=0)][string] $Path, # The HTTP verb of the endpoint to extract the schema from. [Parameter(Position=1)][string] $Method = '*', # The HTTP path of the endpoint to extract the schema from. [Parameter(Position=2)][Alias('ApiPath')][string] $EndpointPath = '*', # Indicates that the request schema of the endpoint should be returned. [Parameter(ParameterSetName='RequestSchema')][Alias('In')][switch] $RequestSchema, # Indicates the HTTP status code of the response schema of the endpoint that should be returned. [Parameter(ParameterSetName='ResponseStatus',Position=3)][int] $ResponseStatus = 200 ) Process { $Method = $Method.ToLowerInvariant() return ($PSCmdlet.ParameterSetName -eq 'RequestSchema' ` ? (Export-Json "/paths/$EndpointPath/$Method/parameters/*/schema" -Path $Path) : (Export-Json "/paths/$EndpointPath/$Method/responses/$ResponseStatus/content/*/schema" -Path $Path) ) | Set-Json '/$schema' 'http://json-schema.org/draft-04/schema#' } } function Find-DotNetTools { <# .SYNOPSIS Returns a list of matching dotnet tools. .FUNCTIONALITY DotNet .EXAMPLE Find-DotNetTools interactive |Format-Table -AutoSize PackageName Version Authors Downloads Verified ----------- ------- ------- --------- -------- microsoft.dotnet-interactive 1.0.516401 Microsoft 33682741 True dotnet-repl 0.1.216 jonsequitur 117599 False #> [CmdletBinding()] Param( # The name of the tool to search for. [Parameter(Position=0,Mandatory=$true)][string] $Name ) if(!(Get-Command dotnet -Type Application -ErrorAction Ignore)) { throw 'The dotnet CLI was not found.' } foreach($line in dotnet tool search $Name |Where-Object {$_ -match '^\S+\s+\d+(?:\.\d+)+\b'}) { $package,$version,$authors,$downloads,$verified = $line -split '\s\s+',5 [pscustomobject]@{ PackageName = $package Version = try{[semver]$version}catch{try{[version]$version}catch{$version}}; Authors = $authors Downloads = [long]$downloads Verified = $verified.Trim() -eq 'x' } } } function Get-AssemblyFramework { <# .SYNOPSIS Gets the framework version an assembly was compiled for. .INPUTS Objects with System.String properties named Path or FullName. .OUTPUTS System.Management.Automation.PSCustomObject with RuntimeVersion and CompileVersion properties. .FUNCTIONALITY DotNet .LINK https://stackoverflow.com/questions/3460982/determine-net-framework-version-for-dll#25649840 .EXAMPLE Get-AssemblyFramework Program.exe RuntimeVersion CompileVersion -------------- -------------- v4.0.30319 .NETFramework,Version=v4.7.2 #> [CmdletBinding()][OutputType([Management.Automation.PSCustomObject])] Param( # The assembly to get the framework version of. [Parameter(Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true)][Alias('FullName')][string] $Path ) Process { $assembly = [Reflection.Assembly]::ReflectionOnlyLoadFrom((Resolve-Path $Path)) [PSCustomObject]@{ RuntimeVersion = $assembly.ImageRuntimeVersion CompileVersion = $assembly.CustomAttributes | Where-Object {$_.AttributeType.Name -eq "TargetFrameworkAttribute" } | ForEach-Object {$_.ConstructorArguments.value} } } } function Get-GitFileMetadata { <# .SYNOPSIS Returns the creation and last modification metadata for a file in a git repo. .FUNCTIONALITY Git and GitHub .LINK Get-ChildItem .LINK Resolve-Path .EXAMPLE Get-GitFileMetadata README.md Path : .\README.md CreateCommit : 1fde7af CreateAuthor : Brian Lalonde CreateEmail : brianary@example.com CreateDate : 01/19/2015 11:44:15 LastCommit : dbe27ba LastAuthor : Brian Lalonde LastEmail : brianary@example.com LastDate : 12/07/2020 20:17:15 #> [CmdletBinding()][OutputType([psobject])] Param( # The path (or paths) to get metadata for. [Parameter(Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true,ValueFromRemainingArguments=$true)] [string[]] $Path, # Recurse into subdirectories. [switch] $Recurse ) Begin { if(!(Get-Command git -Type Application -ErrorAction Ignore)) {throw "Git is required to be installed!"} } Process { foreach($f in Get-ChildItem $Path -Recurse:$Recurse) { $create_commit,$create_author,$create_email,$create_date = (git log --reverse --format="%h%x09%cn%x09%ae%x09%ai" $f |Select-Object -f 1) -split '\t' $last_commit,$last_author,$last_email,$last_date = (git log -1 --format="%h%x09%cn%x09%ae%x09%ai" $f |Select-Object -f 1) -split '\t' [pscustomobject]@{ Path = Resolve-Path $f -Relative CreateCommit = $create_commit CreateAuthor = $create_author CreateEmail = $create_email CreateDate = [datetime]$create_date LastCommit = $last_commit LastAuthor = $last_author LastEmail = $last_email LastDate = [datetime]$last_date } } } } function Get-GitFirstCommit { <# .SYNOPSIS Gets the SHA-1 hash of the first commit of the current repo. .OUTPUTS System.String containing the SHA-1 hash of this repo's first commit. .FUNCTIONALITY Git and GitHub .EXAMPLE Get-GitFirstCommit 1fde7af20e8560c720d42227495e8d15459aafa4 #> [CmdletBinding()][OutputType([string])] Param() if(!(Get-Command git -Type Application -ErrorAction Ignore)) {throw "Git is required to be installed!"} git log --max-parents=0 --format=format:%H HEAD } function Get-GitHubRepoChildItem { <# .SYNOPSIS Gets the items and child items in one or more specified locations. .EXAMPLE Get-GitHubRepoChildItem -Filter *.csproj -Recurse -File -OwnerName PowerShell -RepositoryName PSScriptAnalyzer |Format-Table name,size,path -AutoSize name size path ---- ---- ---- Engine.csproj 3679 Engine/Engine.csproj Rules.csproj 2586 Rules/Rules.csproj .EXAMPLE Get-GitHubRepoChildItem -Path src -AlternatePath / -Filter LICENSE -File -OwnerName PowerShell -RepositoryName PSScriptAnalyzer name : LICENSE path : LICENSE sha : cec380d8ef7f7a1ad3ff9ef2356a88a13a78b491 size : 1089 url : https://api.github.com/repos/PowerShell/PSScriptAnalyzer/contents/LICENSE?ref=master html_url : https://github.com/PowerShell/PSScriptAnalyzer/blob/master/LICENSE git_url : https://api.github.com/repos/PowerShell/PSScriptAnalyzer/git/blobs/cec380d8ef7f7a1ad3ff9ef2356a88a13a78b491 download_url : https://raw.githubusercontent.com/PowerShell/PSScriptAnalyzer/master/LICENSE type : file _links : @{self=https://api.github.com/repos/PowerShell/PSScriptAnalyzer/contents/LICENSE?ref=master; git=https://api.github.com/repos/PowerShell/PSScriptAnalyzer/git/blobs/cec380d8ef7f7a1ad3ff9ef2356a88a13a78b491; html=https://github.com/PowerShell/PSScriptAnalyzer/blob/master/LICENSE} #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars','', Justification='Using a global variable to cache responses, to avoid abusive API iteration.')] [CmdletBinding()] Param( # The path for which to retrieve contents. [Parameter(Position=0)][string] $Path = '', # Specifies a wildcard pattern to filter matches against. [string] $Filter = '*', # Specifies a wildcard pattern to exclude matches against. [string] $Exclude = '', # An alternate path to retrieve if the primary Path isn't found. [string] $AlternatePath, # Indicates subdirectories should be searched. [switch] $Recurse, # Indicates that only files should be returned. [switch] $File, # Indicates that only directories should be returned. [switch] $Directory, # Owner of the repository. [Parameter(ValueFromPipelineByPropertyName)][psobject] $OwnerName, # Name of the repository. [Parameter(ValueFromPipelineByPropertyName)][Alias('Name')][string] $RepositoryName, # The branch, or defaults to the default branch of not specified. [string] $BranchName, # Ignores any cached result and re-queries the GitHub API. [switch] $Force ) Begin { if(!(Get-Variable GitHubRepoContents -Scope Global -ErrorAction SilentlyContinue)) {$Global:GitHubRepoContents = @{}} function Get-PathContentOrAlternate { [CmdletBinding()] Param( [psobject] $OwnerName, [string] $RepositoryName, [string] $Path, [string] $Branch, [string] $AlternatePath ) [void]$PSBoundParameters.Remove('AlternatePath') if($Path -in '','.','/') {[void]$PSBoundParameters.Remove('Path')} try {return Get-GitHubContent @PSBoundParameters} catch [Microsoft.PowerShell.Commands.HttpResponseException] { Write-Verbose "Could not find path $Path in $OwnerName/$RepositoryName" if($_.Exception.Response.StatusCode -ne 404 -or $AlternatePath -eq $null) {throw} if($AlternatePath -in '','.','/') {[void]$PSBoundParameters.Remove('Path')} else {$PSBoundParameters['Path'] = $AlternatePath} return Get-GitHubContent @PSBoundParameters } } } Process { Write-Verbose $MyInvocation.Line if($OwnerName -isnot [string]) {$PSBoundParameters['OwnerName'] = $OwnerName = $OwnerName.UserName} $repoContext, $searchContext = "$OwnerName/$RepositoryName/$BranchName", "$Path|$AlternatePath|$Filter|$Exclude|$Recurse|$File|$Directory" if(!$Global:GitHubRepoContents.ContainsKey($repoContext)) { $Global:GitHubRepoContents[$repoContext] = @{} } elseif(!$Force -and $Global:GitHubRepoContents[$repoContext].ContainsKey($searchContext)) { return $Global:GitHubRepoContents[$repoContext][$searchContext] } $entryType = if($File -and $Directory) {''} elseif($File) {'file'} elseif($Directory) {'dir'} else {'*'} $contentSpec = @{ OwnerName = $OwnerName RepositoryName = $RepositoryName Path = $Path AlternatePath = $AlternatePath } if($BranchName) {$contentSpec += @{BranchName=$BranchName}} Write-Progress "Searching $OwnerName/$RepositoryName $BranchName" ( $Path ? $Path : '/' ) if(!$Force -and $Global:GitHubRepoContents[$repoContext].ContainsKey("$Path|$AlternatePath")) { $content = $Global:GitHubRepoContents[$repoContext]["$Path|$AlternatePath"] } else { $content = Get-PathContentOrAlternate @contentSpec $Global:GitHubRepoContents[$repoContext]["$Path|$AlternatePath"] = $content } if($content.type -eq 'file') { if($content.type -notlike $entryType -or $content.name -notlike $Filter -or $content.name -like $Exclude) {return} $Global:GitHubRepoContents[$repoContext][$searchContext] = $content return $content } $found = @($content.entries | Where-Object {$_.type -like $entryType -and $_.name -like $Filter -and $_.name -notlike $Exclude}) if($Recurse) { $found += @($content.entries | Where-Object {$_.type -eq 'dir' -and $_.name -notlike $Exclude} | ForEach-Object {& $PSCommandPath -Path $_.path -Filter $Filter -Exclude $Exclude -Recurse -File:$File ` -Directory:$Directory -OwnerName $OwnerName -RepositoryName $RepositoryName -BranchName $BranchName}) } $Global:GitHubRepoContents[$repoContext][$searchContext] = $found Write-Progress "Searching $OwnerName/$RepositoryName $BranchName" -Completed return $found } } function Get-GitRepoChangeStatus { <# .SYNOPSIS Indicates whether there are outgoing changes in the local repo or incoming ones from the remote repo. .INPUTS System.IO.DirectoryInfo .LINK https://git-scm.com/docs .EXAMPLE Get-ChildItem ~/GitHub -Directory |Get-GitRepoChangeStatus |Format-Table In Out Repository -- --- ---------- ↑ Codesmithy ↑ Databaseline SelectHtml SelectXmlExtensions ↑ Unicodery ↓ ↑ webcoder #> [CmdletBinding()] Param( # The directory containing the local git repository. [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][IO.DirectoryInfo] $Repository, # Indicates that Emoji status characters should be returned. [switch] $AsEmoji ) Begin { $Script:incoming,$Script:outgoing = $AsEmoji ? @('📥️','📤️') : @('↓','↑') } Process { try { Push-Location $Repository.FullName return [pscustomobject]@{ In = try{(git diff --summary '@{u}') ? $incoming : $null} catch {$_}; Out = try{(git diff --name-only '@{u}') ? $outgoing : $null} catch {$_}; Repository = $Repository.Name } } finally {Pop-Location} } } function Get-LibraryVulnerabilityInfo { <# .SYNOPSIS Get the list of module/package/library vulnerabilities from the RetireJS or SafeNuGet projects. .INPUTS System.String of a package/module name to search for. .OUTPUTS System.Management.Automation.PSCustomObject with details about any vulnerabilities found. .FUNCTIONALITY Packages and libraries .LINK Invoke-RestMethod .LINK Select-Xml .EXAMPLE Get-LibraryVulnerabilityInfo Backbone.js atOrAbove below identifiers info --------- ----- ----------- ---- 0.5.0 @{release=0.5.0; summary=cross-site scripting vulnerability} {http://backbonejs.org/#changelog} .EXAMPLE Get-LibraryVulnerabilityInfo Backbone.js -Repository nuget id before infoUri -- ------ ------- Backbone.js 0.5.3 http://backbonejs.org/#changelog #> [CmdletBinding()][OutputType([Management.Automation.PSCustomObject])] Param( # The name of the module or package or library to check. [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)] [Alias('Module','Package','Library')][string]$Name, # Whether to check the NPM, JS, or NuGet vulnerability lists. [ValidateSet('js','npm','nuget')][string]$Repository = 'js' ) Process { if($Repository -eq 'nuget') { Invoke-RestMethod https://raw.githubusercontent.com/OWASP/SafeNuGet/master/feed/unsafepackages.xml | Select-Xml //package | Select-Object -ExpandProperty Node | Where-Object {$_.id -eq $Name} | ForEach-Object {[pscustomobject]@{id=$_.id;before=$_.before;infoUri=$_.infoUri}} } else { $lib = Invoke-RestMethod https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/${Repository}repository.json if($lib.$Name) {$lib.$Name.vulnerabilities |Select-Object atOrAbove,below,identifiers,info} else {Write-Warning "$Name not found"} } } } function Get-NuGetConfigs { <# .SYNOPSIS Returns the available NuGet configuration files, in order of preference. .OUTPUTS System.String containing the path to a NuGet config file. .FUNCTIONALITY Configuration .LINK https://docs.myget.org/docs/how-to/nuget-configuration-inheritance .EXAMPLE Get-NuGetConfigs C:\Users\zaphodb\GitHub\ProjectX\src\nuget.config C:\Users\zaphodb\AppData\Roaming\NuGet\NuGet.config C:\ProgramData\NuGet\Config.config C:\ProgramData\NuGet\NuGetDefaults.config #> [CmdletBinding()][OutputType([string])] Param( # The directory to walk the parents of, to look for configs. [Parameter(Position=0)][string] $Directory = "$PWD" ) function Get-Parent([Parameter(Position=0)][string] $Directory) { if($Directory -eq "$(Join-Path (Split-Path $Directory -Qualifier) '')") {$Directory} else {$Directory; Get-Parent (Split-Path $Directory)} } Get-Parent $Directory |ForEach-Object {Join-Path $_ nuget.config} |Where-Object {Test-Path $_ -Type Leaf} Join-Path $env:APPDATA NuGet NuGet.config |Where-Object {Test-Path $_ -Type Leaf} Join-Path $env:ProgramData NuGet Config*.config |Resolve-Path -ErrorAction Ignore |Sort-Object Length -Descending Join-Path $env:ProgramData NuGet NuGetDefaults.config |Where-Object {Test-Path $_ -Type Leaf} } function Get-OpenApiInfo { <# .SYNOPSIS Returns metadata from an OpenAPI definition. .FUNCTIONALITY Json .LINK https://www.openapis.org/ .EXAMPLE Get-OpenApiInfo .\test\data\sample-openapi.json Source : .\test\data\sample-openapi.json OpenApi : 3.0.3 Title : Sample REST API Description : An example OpenAPI definition. Version : 1.0.0 Endpoints : {@{Endpoint=GET /users/{userId}; Summary=Returns a user by ID.; Description=Gets a user's details.}, | @{Endpoint=POST /users; Summary=Creates a new user.; Description=Adds a user account.}} #> [CmdletBinding()] Param( [Parameter(Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true)][Alias('FullName')][string] $Path ) Process { Get-Content $Path -Raw | ConvertFrom-Json -AsHashtable | ForEach-Object {[pscustomobject]@{ Source = $Path OpenApi = $_.ContainsKey('openapi') ? $_.openapi : $_.ContainsKey('swagger') ? $_.swagger : $null Title = $_.info.title Description = $_.info.description Version = $_.info.version Endpoints = $_.paths.GetEnumerator() |% { $p = $_.Key $e = $_.Value $_.Value.Keys |% { [pscustomobject]@{ Endpoint = "$($_.ToUpperInvariant()) $p" Summary = $e[$_].summary Description = $e[$_].description } } } }} } } function Get-RepoName { <# .SYNOPSIS Gets the name of the repo. .INPUTS Objects with System.String Path or FullName properties. .OUTPUTS System.String of the repo name (the final segment of the first remote location). .FUNCTIONALITY Git and GitHub .EXAMPLE Get-RepoName MyRepository #> [CmdletBinding()][OutputType([string])] Param( # The path to the git repo to get the name for. [Parameter(Position=0,ValueFromPipelineByPropertyName=$true)] [Alias('FullName')][string] $Path = $PWD.Path ) Begin { if(!(Get-Command git -Type Application -ErrorAction Ignore)) {throw "Git is required to be installed!"} } Process { if(!(Test-Path $Path -Type Container)) {throw "The path $Path was not found."} try { Push-Location $Path git status |Out-Null if(!$?) {throw "The path $Path is not a git repo."} $remote = git remote |Select-Object -First 1 if($remote) {return ([uri](git remote get-url $remote)).Segments[-1] -replace '\.git\z',''} else {return Split-Path $Path -Leaf} } finally {Pop-Location} } } function Get-Todos { <# .SYNOPSIS Returns the TODOs for the current git repo, which can help document technical debt. .EXAMPLE Get-Todos |Out-GridView -Title "$((Get-Item $(git rev-parse --show-toplevel)).Name) TODOs" Shows TODOs in this repo. #> [CmdletBinding()] Param() Push-Location $(git rev-parse --show-toplevel) #TODO: Add or replace dependencies Find-Lines.ps1 -Pattern '\bTODO\b' -Filters * -Path ((Test-Path src -Type Container) ? 'src' : '.') -CaseSensitive | ForEach-Object { [string[]] $blame = git blame -p -L "$($_.LineNumber),$($_.LineNumber)" -- $_.Path $author = $blame |Select-String '^author (?<Author>.*)$' |Select-CapturesFromMatches.ps1 -ValuesOnly $Time = $blame |Select-String '^author-time (?<Time>.*)$' |Select-CapturesFromMatches.ps1 -ValuesOnly | ConvertFrom-EpochTime.ps1 [pscustomobject]@{ Author = $author Time = $time Todo = ($_.Line -split 'TODO:?\s*',2)[1].Trim() Path = Resolve-Path $_.Path -Relative LineNumber = $_.LineNumber } } | Sort-Object Time -Descending Pop-Location } function Get-VSCCurrentFile { <# .SYNOPSIS Returns the path of the current file open in VSCode, when run in the PowerShell Extension Terminal in VSCode. .OUTPUTS System.String, System.Double, System.Int32, System.Boolean depending on VS Code JSON value type. .FUNCTIONALITY VSCode .LINK https://github.com/PowerShell/PowerShellEditorServices .LINK https://marketplace.visualstudio.com/items?itemName=ms-vscode.PowerShell .EXAMPLE Get-VSCCurrentFile C:\GitHub\scripts\Get-VSCCurrentFile #> [CmdletBinding()][OutputType([string])] Param() $psEditor.GetEditorContext().CurrentFile.Path } function Get-VSCodeSetting { <# .SYNOPSIS Sets a VSCode setting. .OUTPUTS System.String, System.Double, System.Int32, System.Boolean depending on VS Code JSON value type. .FUNCTIONALITY VSCode .LINK https://code.visualstudio.com/docs/getstarted/settings .LINK Get-VSCodeSettingsFile .LINK ConvertFrom-Json .LINK Get-Content .EXAMPLE Get-VSCodeSetting /editor.renderWhitespace all .EXAMPLE Get-VSCodeSetting /powershell.codeFormatting.preset -Workspace Allman .EXAMPLE Get-VSCodeSetting /prettier.disableLanguages -Workspace markdown #> [CmdletBinding()][OutputType([string])][OutputType([double])] [OutputType([int])][OutputType([bool])] Param( <# The full path name of the property to set, as a JSON Pointer, which separates each nested element name with a /, and literal / is escaped as ~1, and literal ~ is escaped as ~0. #> [Parameter(Position=0,Mandatory=$true)][Alias('Name')][AllowEmptyString()][ValidatePattern('\A(?:|/(?:[^~]|~0|~1)*)\z')] [string] $JsonPointer, # Indicates that the current workspace settings should be [switch] $Workspace ) ${settings.json} = Get-VSCodeSettingsFile -Workspace:$Workspace if(!(Test-Path ${settings.json} -Type Leaf)) {return $null} #TODO: Add or replace dependency. return Select-Json.ps1 $JsonPointer -Path ${settings.json} } function Get-VSCodeSettingsFile { <# .SYNOPSIS Gets the path of the VSCode settings.config file. .OUTPUTS System.String containing the path of the settings.config file. .FUNCTIONALITY VSCode .LINK https://code.visualstudio.com/docs/getstarted/settings .LINK https://powershell.github.io/PowerShellEditorServices/api/Microsoft.PowerShell.EditorServices.Extensions.EditorObject.html .LINK https://git-scm.com/docs/git-rev-parse .LINK Join-Path .LINK Get-Command .EXAMPLE Get-VSCodeSettingsFile C:\Users\zaphodb\AppData\Roaming\Code\User\settings.json .EXAMPLE Get-VSCodeSettingsFile -Workspace C:\Users\zaphodb\GitHub\scripts\.vscode\settings.json #> [CmdletBinding()][OutputType([string])] Param( # Indicates that the current workspace settings should be parsed instead of the user settings. [switch] $Workspace ) ${settings.json} = if($Workspace) { if(!(Get-Command git -Type Application -ErrorAction Ignore)) {throw "Git is required to be installed!"} if(Get-Variable psEditor -Scope Global -ErrorAction Ignore) { Join-Path $psEditor.Workspace.Path .vscode/settings.json } elseif ((Get-Command git -ErrorAction Ignore) -and "$(git rev-parse --git-dir)") { Join-Path "$(git rev-parse --show-toplevel)" .vscode/settings.json } else { Write-Warning "Can't detect VSCode workspace or git repo. Assuming current directory." Join-Path "$PWD" .vscode/settings.json } } else { if(!(Test-Path variable:IsWindows)) { Set-Variable IsWindows ($PSVersionTable.PSEdition -eq 'Desktop' -or $env:OS -eq 'Windows_NT') } if($IsWindows) { # could also try Resolve-Path "$env:APPDATA\Code*\User\settings.json", but this is better targetted "$env:APPDATA\Code - Insiders\User\settings.json","$env:APPDATA\Code\User\settings.json" | Where-Object {Test-Path $_ -PathType Leaf} | Select-Object -First 1 } elseif($IsLinux) { "$HOME/.config/Code/User/settings.json" } elseif($IsMacOS) { "$HOME/Library/Application Support/Code/User/settings.json" } else { throw 'Unable to determine location of VSCode settings.json' } } Write-Verbose "Using VSCode settings ${settings.json}" ${settings.json} } function Import-VsCodeDatabaseConnections { <# .SYNOPSIS Adds config XDT connection strings to VSCode settings. .FUNCTIONALITY VSCode .LINK https://marketplace.visualstudio.com/items?itemName=ms-mssql.mssql .LINK http://code.visualstudio.com/ .LINK https://git-scm.com/docs/git-rev-parse .LINK Add-VsCodeDatabaseConnection .LINK Get-ConfigConnectionStringBuilders .EXAMPLE Import-VsCodeDatabaseConnections Adds any new (by name) connection strings found in XDT .config files into the .vscode/settings.json mssql.connections collection for the mssql extension. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns','', Justification='All configurations are returned as a collection.')] [CmdletBinding()][OutputType([void])] Param() function Get-ConfigConnections { $connections = @() foreach($config in (Get-ChildItem -Recurse -Filter *.config |Select-Object -ExpandProperty FullName)) { #TODO: Add or replace dependency. foreach($cs in (Get-ConfigConnectionStringBuilders.ps1 $config)) { $name = $cs.Name + '.' + ($config |Split-Path -LeafBase |Split-Path -Extension).Trim('.') if($connections -and $name -in $connections.profileName){Write-Verbose "A '$name' connection already exists."; continue} #TODO: Add or replace dependency. Import-Variables.ps1 $cs.ConnectionString $vsconn = @{ ProfileName = $name ServerInstance = ${Data Source} Database = ${Data Source} } if(!${Integrated Security}) {$vsconn += @{UserName=${User ID}}} Write-Verbose "Found '$($vsconn.ProfileName)' in $config" $connections += [pscustomobject]$vsconn } } Write-Verbose "Found $($connections.Count) connection strings." $connections } $connections = Get-ConfigConnections if(!$connections){Write-Verbose 'Nothing to do.'} else{$connections |Add-VsCodeDatabaseConnection} } function New-Jwt { <# .SYNOPSIS Generates a JSON Web Token (JWT) .OUTPUTS System.String of an encoded, signed JWT .FUNCTIONALITY Data formats .LINK https://tools.ietf.org/html/rfc7519 .LINK https://jwt.io/ .LINK https://docs.microsoft.com/dotnet/api/system.security.cryptography.hmac .EXAMPLE New-Jwt -Subject 1234567890 -IssuedAt 2018-01-18T01:30:22Z -Secret (ConvertTo-SecureString swordfish -AsPlainText -Force) eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyfQ.59noQVrGQKetFM3RRTe9m4MVBUMkLo3WxqqpPf1xJ-U #> [CmdletBinding()][OutputType([string])] Param( # A hash of JWT body elements. [ValidateNotNull()][System.Collections.IDictionary] $Body = @{}, # Custom headers (beyond typ and alg) to add to the JWT. [ValidateNotNull()][System.Collections.IDictionary] $Headers = @{}, # A secret used to sign the JWT. [Parameter(Mandatory=$true)][ValidateNotNull()][securestring] $Secret, # The hashing algorithm class to use when signing the JWT. [ValidateSet('HS256','HS384','HS512')][ValidateNotNull()][string] $Algorithm = 'HS256', # When the JWT becomes valid. [datetime] $NotBefore, # Specifies when the JWT was issued. [datetime] $IssuedAt, # Indicates the issued time should be included, based on the current datetime (ignored if IssuedAt is provided). [switch] $IncludeIssuedAt, # When the JWT expires. [datetime] $ExpirationTime, # How long from now until the JWT expires (ignored if ExpirationTime is provided). [timespan] $ExpiresAfter, # A unique (at least within a given issuer) identifier for the JWT. [ValidateNotNullOrEmpty()][string] $JwtId, # A string or URI (if it contains a colon) indicating the entity that issued the JWT. [ValidateNotNullOrEmpty()][ValidateScript({if($_.Contains(':')){return Test-Uri $_}else{$true}})][string] $Issuer, # The principal (user) of the JWT as a string or URI (if it contains a colon). [ValidateNotNullOrEmpty()][ValidateScript({if($_.Contains(':')){return Test-Uri $_}else{$true}})][string] $Subject, # A string or URI (if it contains a colon), or a list of string or URIs that indicates who the JWT is intended for. [ValidateScript({foreach($s in $_){if($s.Contains(':') -and !(Test-Uri $s)){return $false}};return $true})] [ValidateCount(1,2147483647)][System.String[]] $Audience, # Additional claims to add to the body of the JWT. [hashtable] $Claims = @{} ) #TODO: Add or replace dependencies. function ConvertTo-JSON64($o) {ConvertTo-Base64 ([Text.Encoding]::UTF8.GetBytes((ConvertTo-Json $o -Compress))) -UriStyle} function ConvertTo-NumericDate([datetime]$d) { if($d.Kind -eq 'Utc') {[int](Get-Date $d -UFormat %s)} else {[int](Get-Date $d.ToUniversalTime() -UFormat %s)} } function ConvertFrom-NumericDate([int]$i) { if(!$i) {return} (New-Object datetime 1970,1,1,0,0,0,([DateTimeKind]::Utc)).AddSeconds($i).ToLocalTime() } $Headers['alg'] = $Algorithm $Headers['typ'] = 'JWT' if($NotBefore) {$Body['nbf'] = ConvertTo-NumericDate $NotBefore} if($IncludeIssuedAt) {$Body['iat'] = ConvertTo-NumericDate (Get-Date)} elseif($IssuedAt) {$Body['iat'] = ConvertTo-NumericDate $IssuedAt} if($ExpirationTime) {$Body['exp'] = ConvertTo-NumericDate $ExpirationTime} elseif($ExpiresAfter) {$Body['exp'] = ConvertTo-NumericDate ((Get-Date).Add($ExpiresAfter))} 'iat','nbf','exp' |ForEach-Object {Set-Variable $_ $(if($Body.ContainsKey($_)){ConvertFrom-NumericDate $Body[$_]}else{'whenever'})} Write-Verbose "JWT issued $iat valid $nbf to $exp" if($JwtId) {$Body['jti'] = $JwtId} if($Issuer) {$Body['iss'] = $Issuer} if($Subject) {$Body['sub'] = $Subject} if($Audience) {$Body['aud'] = $Audience} $Body += $Claims Write-Verbose "JWT headers: $(ConvertTo-Json $Headers -Compress)" Write-Verbose "JWT body: $(ConvertTo-Json $Body -Compress)" $jwt = "$(ConvertTo-JSON64 $Headers).$(ConvertTo-JSON64 $Body)" Write-Verbose "Unsigned JWT: $jwt" $secred = New-Object pscredential 'secret',$Secret [byte[]]$secbytes = [Text.Encoding]::UTF8.GetBytes(($secred.Password |ConvertFrom-SecureString -AsPlainText)) $hash = New-Object "Security.Cryptography.$($Algorithm -replace '\AHS','HMACSHA')" (,$secbytes) $secbytes = $null Write-Verbose "Signing JWT with $($hash.GetType().Name)" $jwt = "$jwt.$(ConvertTo-Base64 ($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes($jwt))) -UriStyle)" $hash.Dispose() $jwt } function Push-WorkspaceLocation { <# .SYNOPSIS Pushes the current VS Code editor workspace location to the location stack. .FUNCTIONALITY VSCode .LINK Push-Location .EXAMPLE Push-WorkspaceLocation Pushes the current directory onto the stack, and changes to the workspace directory. #> [CmdletBinding()][OutputType([void])] Param() #TODO: Add or replace dependency. if(Test-Variable.ps1 psEditor) {Push-Location $psEditor.Workspace.Path} else {throw 'Missing psEditor object'} } function Rename-GitHubLocalBranch { <# .SYNOPSIS Rename a git repository branch. .FUNCTIONALITY Git and GitHub .LINK https://docs.github.com/en/github/administering-a-repository/managing-branches-in-your-repository/renaming-a-branch#updating-a-local-clone-after-a-branch-name-changes .LINK https://github.com/github/renaming .EXAMPLE Rename-GitHubLocalBranch main Rename the current branch to "main". #> [CmdletBinding(ConfirmImpact='High',SupportsShouldProcess=$true)] Param( # The new branch name. [Parameter(Position=0,Mandatory=$true)][string] $NewName ) if(!$PSCmdlet.ShouldContinue('Have you renamed the branch in the GitHub UI?','GitHub Status')) { Write-Information 'Rename the branch via the GitHub UI before running this script.' if((Get-Command gh -CommandType Application -ErrorAction Ignore)) {gh browse} Start-Process 'https://docs.github.com/en/github/administering-a-repository/managing-branches-in-your-repository/renaming-a-branch#renaming-a-branch' return } $oldName = git rev-parse --abbrev-ref HEAD if(!$PSCmdlet.ShouldProcess("$oldName branch","rename to $NewName")) {return} if(!(Get-Command git -Type Application -ErrorAction Ignore)) {throw "Git is required to be installed!"} Write-Verbose "git branch -m $oldName $NewName" git branch -m $oldName $NewName Write-Verbose "git fetch origin" git fetch origin Write-Verbose "git branch -u origin/$NewName $NewName" git branch -u "origin/$NewName" $NewName Write-Verbose 'git remote set-head origin -a' git remote set-head origin -a Write-Verbose 'git remote prune origin' git remote prune origin } function Repair-ScriptStyle { <# .SYNOPSIS Accepts justifications for script analysis rule violations, fixing the rest using Invoke-ScriptAnalysis. .FUNCTIONALITY Scripts .LINK https://docs.microsoft.com/powershell/module/psscriptanalyzer/invoke-scriptanalyzer .EXAMPLE Repair-ScriptStyle .\MyScript.ps1 PSAvoidUsingWriteHost in A:\Scripts\MyScript.ps1 (!) Warning Lines: 19, 24, 25, 26, 27, 31, 32 File 'MyScript.ps1' uses Write-Host. Avoid using Write-Host because it might not work in all hosts, does not work when there is no host, and (prior to PS 5.0) cannot be suppressed, captured, or redirected. Instead, use Write-Output, Write-Verbose, or Write-Information. Confirm Are you sure you want to perform this action? Performing the operation "provide justification" on target "PSAvoidUsingWriteHost in A:\Scripts\MyScript.ps1". [Y] Yes [A] Yes to All [N] No [L] No to All [S] Suspend [?] Help (default is "Y"): #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost','', Justification='This script is not intended for pipeline redirection. Also, it uses color.')] [CmdletBinding(ConfirmImpact='High',SupportsShouldProcess=$true)] Param( # The path to a PowerShell script file to repair the style of. [Parameter(Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true)] [Alias('FullName')][string] $Path ) Process { $suppress = @() foreach($rule in Invoke-ScriptAnalyzer $Path |Group-Object RuleName) { $name = $rule.Name Write-Information " $name in $Path " foreach($severity in $rule.Group |Group-Object Severity) { switch($severity.Name) { Information {Write-Information ' 🆗 Information '} Warning {Write-Information ' ⚠️ Warning '} Error {Write-Information ' ❌ Error '} default {Write-Information " $($severity.Name) "} } foreach($message in $severity.Group |Group-Object Message) { Write-Information " Lines: $($message.Group.Line -join ', ')" Write-Information " $($message.Name)" } } if(!$PSCmdlet.ShouldProcess("$name in $Path",'provide justification')) {continue} $suppress += @" [Diagnostics.CodeAnalysis.SuppressMessageAttribute('$name','', Justification='$((Read-Host 'Justification') -replace "'","''")')] "@ } if($suppress) { (Get-Content $Path -Raw) -replace '(?m)^(\[CmdletBinding\b)',"$suppress`$1" | ForEach-Object {$_.Trim()} | Out-File $Path utf8BOM } Invoke-ScriptAnalyzer $Path -Fix } } function Set-VSCodeSetting { <# .SYNOPSIS Sets a VSCode setting. .FUNCTIONALITY VSCode .LINK https://code.visualstudio.com/docs/getstarted/settings .LINK Get-VSCodeSettingsFile .EXAMPLE Set-VSCodeSetting git.autofetch $true -Workspace Sets {"git.autofetch": true} in the VSCode user settings. .EXAMPLE Set-VSCodeSetting /powershell.codeFormatting.preset Allman -Workspace Sets {"powershell.codeFormatting.preset": "Allman"} in the VSCode workspace settings. .EXAMPLE Set-VSCodeSetting /workbench.colorTheme 'PowerShell ISE' -Workspace Sets {"workbench.colorTheme": "PowerShell ISE"} in the VSCode workspace settings. #> [CmdletBinding()][OutputType([void])] Param( <# The full path name of the property to set, as a JSON Pointer, which separates each nested element name with a /, and literal / is escaped as ~1, and literal ~ is escaped as ~0. #> [Parameter(Position=0,Mandatory=$true)][Alias('Name')][AllowEmptyString()][ValidatePattern('\A(?:|/(?:[^~]|~0|~1)*)\z')] [string] $JsonPointer = '', # The value of the setting to set. [Parameter(Position=1,Mandatory=$true)][AllowEmptyString()][AllowEmptyCollection()][AllowNull()] [psobject] $Value, # Indicates that the current workspace settings should be set, rather than the user settings. [switch] $Workspace ) ${settings.json} = Get-VSCodeSettingsFile -Workspace:$Workspace if(!(${settings.json} |Split-Path |Test-Path -PathType Container)) {mkdir (${settings.json} |Split-Path) |Out-Null} if(!(Test-Path ${settings.json} -PathType Leaf)) {'{}' |Out-File ${settings.json} -Encoding utf8} $settings = Get-Content ${settings.json} -Raw #TODO: Add or replace dependency. $settings |Set-Json.ps1 $JsonPointer $Value -WarnOverwrite |Out-File ${settings.json} -Encoding utf8 } function Show-DataRef { <# .SYNOPSIS Display an HTML view of an XML schema or WSDL using Saxon. .FUNCTIONALITY XML .EXAMPLE Show-DataRef DataModel.xsd (Renders the XML schema as HTML.) #> [CmdletBinding()][OutputType([void])] Param( # System.String containing the path to an XML Schema or WSDL file. [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][string]$SchemaFile ) Begin { #TODO: Add or replace dependency. Use-Command.ps1 saxon $env:ProgramFiles\Saxonica\Saxon*\bin\Transform.exe -nupkg saxon-he ` -InstallDir $env:ProgramFiles\Saxonica } Process { $css = Join-Path $PSScriptRoot 'dataref.css' $xslt = Join-Path $PSScriptRoot 'dataref.xslt' $html = [IO.Path]::ChangeExtension($SchemaFile,'html') Copy-Item $css . C:\ProgramData\chocolatey\bin\SaxonHE\bin\Transform.exe -s:$SchemaFile -xsl:$xslt -o:$html Invoke-Item $html } } function Show-OpenApiInfo { <# .SYNOPSIS Displays metadata from an OpenAPI definition. .FUNCTIONALITY Json .LINK https://www.openapis.org/ .EXAMPLE Show-OpenApiInfo .\test\data\sample-openapi.json Sample REST API v1.0.0 An example OpenAPI definition. .\test\data\sample-openapi.json openapi v3.0.3 GET /users/{userId} Returns a user by ID. Gets a user's details. POST /users Creates a new user. Adds a user account. #> [CmdletBinding()] Param( [Parameter(Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true)][Alias('FullName')][string] $Path ) Process { $api = Get-OpenApiInfo $Path Write-Host $api.Title -ForegroundColor Green -NoNewline Write-Host " $($api.Version) " -ForegroundColor DarkCyan -NoNewline if($api.Description) {Write-Host $api.Description -ForegroundColor DarkGreen} Write-Host $api.Source -ForegroundColor Cyan -NoNewline Write-Host " OpenAPI v$($api.OpenApi)" -ForegroundColor DarkCyan foreach($endpoint in $api.Endpoints) { Write-Host $endpoint.Endpoint -ForegroundColor White -NoNewline Write-Host ' # ' -ForegroundColor DarkGreen -NoNewline Write-Host $endpoint.Summary -ForegroundColor DarkGreen Write-Host $endpoint.Description -ForegroundColor DarkGray } } } function Test-Jwt { <# .SYNOPSIS Determines whether a string is a valid JWT. .INPUTS System.String value to test for a valid URI format. .OUTPUTS System.Boolean indicating that the string can be parsed as a URI. .FUNCTIONALITY Data formats .EXAMPLE Test-Jwt 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOjE1MTYyMzkwMjIsInN1YiI6IjEyMzQ1Njc4OTAifQ.-zAn1et1mf6QHakJbOTt5-p4gv33R7cIikKy8-9aiNs' (ConvertTo-SecureString swordfish -AsPlainText -Force) True #> [CmdletBinding()][OutputType([bool])] Param( # The string to test. [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][AllowEmptyString()][AllowNull()][string] $InputObject, # The secret used to sign the JWT. [Parameter(Position=1,Mandatory=$true)][SecureString] $Secret ) Process { if(!$InputObject) {Write-Verbose 'No JWT input'; return $false} if(!$InputObject.Contains([char]'.')) {Write-Verbose 'JWT is missing a dot'; return $false} $head64,$body64,$sign64 = $InputObject -split '\.' #TODO: Add or replace dependency. $head = ConvertFrom-Base64.ps1 $head64 utf8 -UriStyle Write-Verbose "JWT head: $head" if(!(Test-Json $head)) {Write-Verbose 'JWT header does not decode to valid JSON'; return $false} $head = ConvertFrom-Json $head if($head.typ -ne 'JWT') {Write-Verbose "JWT type is $($head.typ)"; return $false} if($head.alg -notin 'HS256','HS384','HS512') {Write-Verbose "Unsupported algorithm: $($head.alg)"; return $false} $body = ConvertFrom-Base64.ps1 $body64 utf8 -UriStyle Write-Verbose "JWT body: $body" if(!(Test-Json $body)) {Write-Verbose 'JWT body does not decode to valid JSON'; return $false} $body = ConvertFrom-Json $body [byte[]]$sign = ConvertFrom-Base64.ps1 $sign64 -UriStyle $secred = New-Object pscredential 'secret',$Secret [byte[]]$secbytes = [Text.Encoding]::UTF8.GetBytes(($secred.Password |ConvertFrom-SecureString -AsPlainText)) $hash = New-Object "Security.Cryptography.$($head.alg -replace '\AHS','HMACSHA')" (,$secbytes) if(Compare-Object $sign ($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes("$head64.$body64")))) {Write-Verbose "JWT hashes do not match"; return $false} return $true } } function Trace-GitRepoTest { <# .SYNOPSIS Uses git bisect to search for the point in the repo history that the test script starts returning true. .FUNCTIONALITY Git and GitHub .EXAMPLE Trace-GitRepoTest { dotnet build; !$? } Searches the full repo history for the point at which the build broke. #> [CmdletBinding()] Param( # A script block that returns a boolean corresponding to a state introduced at some point in the repo history. [Parameter(Position=0,Mandatory=$true)][ScriptBlock] $TestScript, # A commit from the repo history without the new state. [Parameter(Position=1)][string] $GoodCommit = $(Get-GitFirstCommit), # A commit from the repo history with the new state. [Parameter(Position=2)][string] $BadCommit = $(git rev-parse HEAD) ) git bisect start |Write-Verbose git bisect good $GoodCommit |Write-Verbose git bisect bad $BadCommit |Write-Verbose do { $state = if($TestScript.Invoke()) {'bad'} else {'good'} $result = git bisect $state $result |Write-Verbose } while($result |Select-String '\ABisecting: ') git bisect reset |Write-Verbose return $result } Export-ModuleMember -Function Add-GitHubMetadata,Add-NotebookCell,Add-VsCodeDatabaseConnection,Copy-GitHubLabels,Export-OpenApiSchema,Find-DotNetTools,Get-AssemblyFramework,Get-GitFileMetadata,Get-GitFirstCommit,Get-GitHubRepoChildItem,Get-GitRepoChangeStatus,Get-LibraryVulnerabilityInfo,Get-NuGetConfigs,Get-OpenApiInfo,Get-RepoName,Get-Todos,Get-VSCCurrentFile,Get-VSCodeSetting,Get-VSCodeSettingsFile,Import-VsCodeDatabaseConnections,New-Jwt,Push-WorkspaceLocation,Rename-GitHubLocalBranch,Repair-ScriptStyle,Set-VSCodeSetting,Show-DataRef,Show-OpenApiInfo,Test-Jwt,Trace-GitRepoTest |