Files
2026-07-13 13:13:17 +08:00

167 lines
6.1 KiB
PowerShell

<#
.SYNOPSIS
Read feature tickets from the TiXL road-map GitHub Projects (v2) board.
.DESCRIPTION
Queries the org-level Projects v2 board ("TiXL road map", org `tixl3d`,
project number 3) through the GitHub GraphQL API via `gh api graphql`.
Read-only. This script never writes to GitHub — it does not move cards,
comment, close, or edit anything. It only lists / fetches tickets so the
`process-tickets` skill can work them locally.
Output is always JSON on stdout — consumers (the `process-tickets` skill,
ad-hoc shell pipes) parse it. Errors go to stderr with a non-zero exit.
Requires the `read:project` scope on the gh token. Projects v2 data is
only reachable through GraphQL (no anonymous / REST path), and a classic
token is refused the `projectV2` field without that scope even though the
board itself is public. Add it once with:
gh auth refresh -s read:project
.PARAMETER List
List every board item whose Status equals -Status (default "In progress")
as a JSON array. Each record carries the issue number, title, url, repo,
size, milestone, labels and the full body — enough to work the ticket
without a second call.
.PARAMETER Ticket
Fetch a single board item by its issue number (full record, same shape as
a -List element). Convenience for re-reading one ticket.
.PARAMETER Status
Status column to filter by. Default "In progress". Valid board values:
Todo, In progress, Done, Closed.
.PARAMETER Owner
Org login that owns the board. Default "tixl3d".
.PARAMETER Project
Projects v2 number. Default 3.
.EXAMPLE
.\Scripts\board-tickets.ps1 -List
.\Scripts\board-tickets.ps1 -List -Status Todo
.\Scripts\board-tickets.ps1 -Ticket 1081
#>
[CmdletBinding(DefaultParameterSetName = 'List')]
param(
[Parameter(ParameterSetName = 'List')]
[switch]$List,
[Parameter(ParameterSetName = 'Ticket', Mandatory = $true)]
[int]$Ticket,
[Parameter(ParameterSetName = 'List')]
[Parameter(ParameterSetName = 'Ticket')]
[string]$Status = 'In progress',
[string]$Owner = 'tixl3d',
[int]$Project = 3
)
$ErrorActionPreference = 'Stop'
function Write-ErrLine([string]$msg) { [Console]::Error.WriteLine($msg) }
# Page through all board items, return the raw node objects whose Status
# matches $Status. Projects v2 GraphQL has no server-side field filter, so we
# page (100/req) and filter client-side. 243 items => ~3 requests.
function Get-MatchingNodes {
$nodes = @()
$after = $null
while ($true) {
$afterArg = if ($null -eq $after) { '' } else { ", after: `"$after`"" }
$query = @"
{
organization(login: "$Owner") {
projectV2(number: $Project) {
items(first: 100$afterArg) {
pageInfo { hasNextPage endCursor }
nodes {
status: fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name } }
size: fieldValueByName(name: "Size") { ... on ProjectV2ItemFieldSingleSelectValue { name } }
milestone: fieldValueByName(name: "Milestone") { ... on ProjectV2ItemFieldMilestoneValue { milestone { title } } }
content {
... on Issue {
number title url state
repository { nameWithOwner }
labels(first: 15) { nodes { name } }
body
}
... on DraftIssue { title body }
}
}
}
}
}
}
"@
# Pass the query via a temp file (-F query=@file). Windows PowerShell
# 5.1 mangles embedded double quotes when handing a string argument to
# a native exe, which would strip the quotes off our GraphQL literals.
# Do NOT use 2>&1 here: Windows PowerShell 5.1 wraps a native exe's
# stderr lines as ErrorRecords and merges them into the output, which
# corrupts the JSON parse. Capture stdout only; let gh's own stderr
# (including INSUFFICIENT_SCOPES) flow to the console.
$tmp = [System.IO.Path]::GetTempFileName()
try {
[System.IO.File]::WriteAllText($tmp, $query, (New-Object System.Text.UTF8Encoding($false)))
$raw = gh api graphql -F query=@$tmp
}
finally {
Remove-Item $tmp -ErrorAction SilentlyContinue
}
if ($LASTEXITCODE -ne 0) {
Write-ErrLine 'GraphQL query failed (gh error above).'
Write-ErrLine 'If it mentions INSUFFICIENT_SCOPES, run: gh auth refresh -s read:project'
exit 1
}
$data = $raw | ConvertFrom-Json
# gh api graphql returns the full envelope: { data: { organization: ... } }
$page = $data.data.organization.projectV2.items
foreach ($n in $page.nodes) {
if ($n.status.name -eq $Status) { $nodes += $n }
}
if (-not $page.pageInfo.hasNextPage) { break }
$after = $page.pageInfo.endCursor
}
return $nodes
}
# Project a raw node down to the slim record the skill consumes.
function ConvertTo-Record($n) {
[PSCustomObject]@{
number = $n.content.number
title = $n.content.title
url = $n.content.url
repo = $n.content.repository.nameWithOwner
state = $n.content.state
status = $n.status.name
size = $n.size.name
milestone = $n.milestone.milestone.title
labels = @($n.content.labels.nodes | ForEach-Object { $_.name })
body = $n.content.body
}
}
$matchNodes = Get-MatchingNodes
if ($PSCmdlet.ParameterSetName -eq 'Ticket') {
$hit = $matchNodes | Where-Object { $_.content.number -eq $Ticket } | Select-Object -First 1
if ($null -eq $hit) {
Write-ErrLine "No '$Status' board item found with issue number $Ticket."
exit 1
}
ConvertTo-Record $hit | ConvertTo-Json -Depth 6 -Compress
exit 0
}
# -List: emit a JSON array (force array even for 0/1 items) as a single line.
# Compact output avoids consumers having to reassemble multi-line pretty JSON
# that PowerShell would otherwise split into a string array across a pipe.
$records = @($matchNodes | ForEach-Object { ConvertTo-Record $_ })
ConvertTo-Json -InputObject @($records) -Depth 6 -Compress