Skip to content

Expose CustomProperties as a PSCustomObject instead of Name-Value array #564

@MariusStorhaug

Description

Context

The GitHubRepository class includes a CustomProperties property that represents the custom property values assigned to a repository. These are organization-defined key-value pairs used for categorization and automation (e.g., Type, SubscribeTo, Environment).

When working with repositories in scripts or automation, custom properties are frequently accessed to make decisions — for example, filtering repositories by type or checking which notification channels a repository subscribes to.

Request

The CustomProperties property on GitHubRepository is currently typed as [GitHubCustomProperty[]] — an array of objects, each with a Name and Value property. Accessing a specific custom property requires filtering the array:

# Current (cumbersome)
$repo.CustomProperties | Where-Object { $_.Name -eq 'Type' } | Select-Object -ExpandProperty Value

The desired experience is to access custom property values directly as properties on an object:

# Desired (intuitive)
$repo.CustomProperties.Type
$repo.CustomProperties.SubscribeTo

This makes the interface consistent with how PowerShell users expect to interact with structured data — using dot-notation on objects rather than filtering arrays of Name-Value pairs.

Multi-select values are flattened to a single string

GitHub custom properties support a multi_select type where the API returns an array of values. The current GitHubCustomProperty class types Value as [string], which causes PowerShell to join the array elements into a single space-delimited string:

# Current behavior — multi-select values are flattened
$repo.CustomProperties | Where-Object Name -eq SubscribeTo | Select-Object -ExpandProperty Value
# Returns: "CODEOWNERS Custom Instructions dependabot.yml gitattributes gitignore Hooks License Linter Settings Prompts PSModule Settings"

# This is a single string, not an array — iterating produces one item
$repo.CustomProperties | Where-Object Name -eq SubscribeTo |
    Select-Object -ExpandProperty Value |
    ForEach-Object { Write-Host "[$_]" }
# Output: [CODEOWNERS Custom Instructions dependabot.yml gitattributes gitignore Hooks License Linter Settings Prompts PSModule Settings]

The expected behavior is that multi-select values are [string[]] arrays, so each value can be iterated individually:

# Desired behavior
$repo.CustomProperties.SubscribeTo
# Returns: @('Custom Instructions', 'License', 'Prompts')

$repo.CustomProperties.SubscribeTo | ForEach-Object { Write-Host "[$_]" }
# Output:
# [Custom Instructions]
# [License]
# [Prompts]

Platform data types

The GitHub platform supports five custom property value types. The conversion must correctly handle all of them:

Value type API value_type API returns Expected PowerShell type
Text string string "some text" [string]
Single select single_select "selected_value" [string]
Multi select multi_select ["val1", "val2"] [string[]]
True/false true_false "true" or "false" [bool]
URL url "https://..." [uri]

Note

The values endpoint (/repos/{owner}/{repo}/properties/values) does not include the value_type in its response — only the property_name and value. The type distinction must be inferred from the value structure: multi_select values arrive as JSON arrays, true_false values arrive as the literal strings "true" or "false" (which must be converted to [bool]), and url values are detected as well-formed absolute URIs and converted to [uri]. All other scalar strings remain as [string].

Allowed characters and limits

Per the GitHub documentation:

  • Property names: a-z, A-Z, 0-9, _, -, $, # only. No spaces. Maximum 75 characters.
  • Property values: All printable ASCII characters except ". Maximum 75 characters.
  • Allowed values (select types): Up to 200 values per property.

Test data

The PSModule/GitHub repository has the following custom properties configured, covering all five value types:

Property Value type Value Expected PowerShell result
Archive true_false true [bool] $true
Description string This is a test [string] 'This is a test'
SubscribeTo multi_select Custom Instructions, License, Prompts [string[]] @('Custom Instructions', 'License', 'Prompts')
Type single_select Module [string] 'Module'
Upstream url https://github.com [uri] 'https://github.com'

Acceptance criteria

  • $repo.CustomProperties is a [PSCustomObject] with dot-notation access
  • $repo.CustomProperties.Type returns 'Module' as [string] (single-select)
  • $repo.CustomProperties.Description returns 'This is a test' as [string] (string)
  • $repo.CustomProperties.SubscribeTo returns @('Custom Instructions', 'License', 'Prompts') as [string[]] (multi-select)
  • $repo.CustomProperties.Archive returns $true as [bool] (true/false)
  • $repo.CustomProperties.Upstream returns a [uri] with value https://github.com (url)
  • Multi-select values are preserved as arrays, not flattened to space-delimited strings
  • Properties with no value set return $null
  • The change applies to both REST API and GraphQL code paths in the GitHubRepository constructor
  • All five platform data types (string, single_select, multi_select, true_false, url) are correctly handled

Technical decisions

Type change for CustomProperties: Change the property type on GitHubRepository from [GitHubCustomProperty[]] to [PSCustomObject]. A PSCustomObject allows dynamic property names derived from the custom property names, enabling dot-notation access. This is the idiomatic PowerShell approach for dynamic key-value data.

Preserve value types from the API: The GitHub API returns multi-select custom property values as JSON arrays (e.g., ["CODEOWNERS", "Custom Instructions", "dependabot.yml"]). The current GitHubCustomProperty class declares [string] $Value, which causes PowerShell to coerce arrays to a single space-delimited string. With the new PSCustomObject approach, values must be stored with proper type handling — arrays stay as [string[]], scalar strings stay as [string], and null stays as $null.

true_false values are converted to [bool]: The GitHub API returns true_false custom property values as the literal strings "true" and "false". These are converted to [bool] ($true / $false) because boolean properties should be boolean in PowerShell. The conversion logic checks if the value equals "true" or "false" (case-insensitive) and converts accordingly. Since the values endpoint does not include value_type, this conversion is applied heuristically to values that are exactly "true" or "false". This is a safe heuristic because string and single_select properties that have "true" or "false" as a value would also benefit from boolean semantics, and the true_false type is the only type that constrains values to exactly these two strings.

url values are converted to [uri]: URL-type custom property values are converted to [uri] using [System.Uri]::new(). The detection heuristic uses [System.Uri]::IsWellFormedUriString($value, [System.UriKind]::Absolute) to identify well-formed absolute URIs before conversion. This correctly identifies values like https://github.com while leaving regular strings and single-select values untouched. The [uri] type is the idiomatic .NET/PowerShell representation for URLs and provides properties like Host, Scheme, AbsolutePath etc.

GitHubCustomProperty class: The existing GitHubCustomProperty class in
src/classes/public/Repositories/GitHubCustomProperty.ps1 is no longer needed for the repository property. It may still be useful for Get-GitHubRepositoryCustomProperty output, so evaluate whether to keep it or also update that function to return a PSCustomObject. Decide during implementation — if the class is not used elsewhere, remove it.

Construction approach (REST API path): In the REST constructor branch ($null -ne $Object.node_id), the custom_properties field from the GitHub REST API is already a flat object with property names as keys and values that may be strings or arrays. Convert it directly with type-appropriate handling:

$customProps = [PSCustomObject]@{}
if ($null -ne $Object.custom_properties) {
    $Object.custom_properties.PSObject.Properties | ForEach-Object {
        $value = if ($_.Value -is [System.Collections.IEnumerable] -and $_.Value -isnot [string]) {
            [string[]]$_.Value
        } elseif ($_.Value -is [string] -and ($_.Value -eq 'true' -or $_.Value -eq 'false')) {
            $_.Value -eq 'true'
        } elseif ($_.Value -is [string] -and [System.Uri]::IsWellFormedUriString($_.Value, [System.UriKind]::Absolute)) {
            [uri]$_.Value
        } else {
            $_.Value
        }
        $customProps | Add-Member -NotePropertyName $_.Name -NotePropertyValue $value
    }
}
$this.CustomProperties = $customProps

Construction approach (GraphQL path): In the GraphQL constructor branch, the data comes as repositoryCustomPropertyValues.nodes — an array of objects with propertyName and value. The value field may be a string or an array. Convert it to a flat PSCustomObject with the same type handling:

$customProps = [PSCustomObject]@{}
if ($null -ne $Object.repositoryCustomPropertyValues -and $null -ne $Object.repositoryCustomPropertyValues.nodes) {
    $Object.repositoryCustomPropertyValues.nodes | ForEach-Object {
        $value = if ($_.value -is [System.Collections.IEnumerable] -and $_.value -isnot [string]) {
            [string[]]$_.value
        } elseif ($_.value -is [string] -and ($_.value -eq 'true' -or $_.value -eq 'false')) {
            $_.value -eq 'true'
        } elseif ($_.value -is [string] -and [System.Uri]::IsWellFormedUriString($_.value, [System.UriKind]::Absolute)) {
            [uri]$_.value
        } else {
            $_.value
        }
        $customProps | Add-Member -NotePropertyName $_.propertyName -NotePropertyValue $value
    }
}
$this.CustomProperties = $customProps

Get-GitHubRepositoryCustomProperty function: Update the output of this function to also return a
PSCustomObject for consistency. The REST endpoint /repos/{owner}/{repo}/properties/values returns an array of { property_name, value } objects — convert to a flat PSCustomObject before returning, applying the same type handling (arrays for multi-select, [bool] for true/false, [uri] for URLs, strings for the rest).

Breaking change: This is a breaking change to the CustomProperties interface. Code that currently
iterates $repo.CustomProperties expecting .Name and .Value properties will need to be updated. However, the new interface is significantly more intuitive and aligns with PowerShell conventions. Consider whether this warrants a Major label — given that custom properties are a relatively new feature and the old interface was not widely documented, treating this as Minor is reasonable.

Test approach: Tests use the PSModule/GitHub repository which has custom properties configured covering all five value types (see Test data section above). Tests are added to tests/Repositories.Tests.ps1 within the existing auth-case-based Context block. The tests retrieve the PSModule/GitHub repo and verify each custom property via dot-notation, checking both value and type. Tests run for all auth cases that have access to read the repository.


Implementation plan

Core changes

  • Change the CustomProperties property type in GitHubRepository class from [GitHubCustomProperty[]] to [PSCustomObject] in src/classes/public/Repositories/GitHubRepository.ps1
  • Update the REST API constructor branch in GitHubRepository to convert custom_properties to a PSCustomObject, preserving array values for multi-select, converting "true"/"false" to [bool], and converting well-formed absolute URIs to [uri]
  • Update the GraphQL constructor branch in GitHubRepository to convert repositoryCustomPropertyValues.nodes to a PSCustomObject, preserving array values for multi-select, converting "true"/"false" to [bool], and converting well-formed absolute URIs to [uri]
  • Update Get-GitHubRepositoryCustomProperty in src/functions/public/Repositories/CustomProperties/Get-GitHubRepositoryCustomProperty.ps1 to return a PSCustomObject instead of raw API response

Cleanup

  • Evaluate whether GitHubCustomProperty class in src/classes/public/Repositories/GitHubCustomProperty.ps1 is still needed; remove if unused
  • Update any internal code that references CustomProperties as an array of GitHubCustomProperty objects

Tests

Tests use the PSModule/GitHub repo with preconfigured custom properties covering all five data types.

  • Add test verifying CustomProperties is a PSCustomObject (not an array) on PSModule/GitHub
  • Add test for Archive (true_false) — $repo.CustomProperties.Archive is [bool] and equals $true
  • Add test for Description (string) — $repo.CustomProperties.Description is [string] and equals 'This is a test'
  • Add test for SubscribeTo (multi_select) — $repo.CustomProperties.SubscribeTo is [string[]] and contains 'Custom Instructions', 'License', 'Prompts'
  • Add test for Type (single_select) — $repo.CustomProperties.Type is [string] and equals 'Module'
  • Add test for Upstream (url) — $repo.CustomProperties.Upstream is [uri] and equals 'https://github.com'
  • Add test for Get-GitHubRepositoryCustomProperty returning a PSCustomObject with the same values

Documentation

  • Update function help for Get-GitHubRepositoryCustomProperty with new output format
  • Add usage example showing dot-notation access: $repo.CustomProperties.Type
  • Add usage example showing multi-select iteration: $repo.CustomProperties.SubscribeTo | ForEach-Object { ... }

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions