Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
432 changes: 432 additions & 0 deletions .agents/skills/finish-pr/SKILL.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .agents/skills/finish-pr/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface:
display_name: "Finish PR"
short_description: "Finish current PR through Gitar review"
default_prompt: "Use $finish-pr to resolve conflicts, failed CI, and all review feedback on the current PR, then continue until Gitar Core reports a clean current-HEAD review."
232 changes: 232 additions & 0 deletions .agents/skills/finish-pr/scripts/get-unresolved-pr-threads.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
[CmdletBinding()]
param(
[int]$PrNumber,
[string]$Repository,
[string]$Hostname,
[switch]$All
)

$ErrorActionPreference = "Stop"

function Invoke-GhJson {
param([Parameter(Mandatory = $true)][string[]]$GhArgs)

$output = & gh @GhArgs 2>&1
if ($LASTEXITCODE -ne 0) {
throw ($output -join [Environment]::NewLine)
}

$json = $output -join [Environment]::NewLine
if ([string]::IsNullOrWhiteSpace($json)) {
return $null
}

return $json | ConvertFrom-Json -Depth 100
}

if ([string]::IsNullOrWhiteSpace($Repository)) {
$repo = Invoke-GhJson @("repo", "view", "--json", "owner,name,url")
$owner = [string]$repo.owner.login
$name = [string]$repo.name
$Repository = "$owner/$name"
if ([string]::IsNullOrWhiteSpace($Hostname)) {
$Hostname = ([uri]$repo.url).Authority
}
} else {
$repositoryParts = $Repository.Split("/")
if ($repositoryParts.Count -eq 3) {
if ([string]::IsNullOrWhiteSpace($Hostname)) {
$Hostname = $repositoryParts[0]
} elseif ($Hostname -ne $repositoryParts[0]) {
throw "-Hostname does not match the hostname in -Repository."
}
$owner = $repositoryParts[1]
$name = $repositoryParts[2]
} elseif ($repositoryParts.Count -eq 2) {
$owner = $repositoryParts[0]
$name = $repositoryParts[1]
} else {
throw "-Repository must use the owner/name or hostname/owner/name format."
}

if ([string]::IsNullOrWhiteSpace($owner) -or [string]::IsNullOrWhiteSpace($name)) {
throw "-Repository contains an empty owner or repository name."
}
}

$repositorySelector = if ([string]::IsNullOrWhiteSpace($Hostname) -or
$Repository.StartsWith("$Hostname/", [StringComparison]::OrdinalIgnoreCase)) {
$Repository
} else {
"$Hostname/$Repository"
}

if ($PrNumber -gt 0) {
$pr = Invoke-GhJson @("pr", "view", $PrNumber.ToString(), "--repo", $repositorySelector, "--json", "number,title,url,headRefName,baseRefName")
} else {
$pr = Invoke-GhJson @("pr", "view", "--repo", $repositorySelector, "--json", "number,title,url,headRefName,baseRefName")
$PrNumber = [int]$pr.number
}

$query = @'
query($owner:String!, $name:String!, $number:Int!, $after:String) {
repository(owner:$owner, name:$name) {
pullRequest(number:$number) {
reviewThreads(first:100, after:$after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
isResolved
isOutdated
path
line
startLine
originalLine
originalStartLine
diffSide
comments(first:100) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
databaseId
body
author {
login
__typename
}
createdAt
updatedAt
url
path
diffHunk
outdated
pullRequestReview {
id
state
submittedAt
commit {
oid
}
}
}
}
}
}
}
}
}
'@

$threads = @()
$after = $null

do {
$ghArgs = @(
"api"
)
if (-not [string]::IsNullOrWhiteSpace($Hostname)) {
$ghArgs += @("--hostname", $Hostname)
}
$ghArgs += @(
"graphql",
"-f", "owner=$owner",
"-f", "name=$name",
"-F", "number=$PrNumber",
"-f", "query=$query"
)

if ($after) {
$ghArgs += @("-f", "after=$after")
}

$result = Invoke-GhJson $ghArgs
$page = $result.data.repository.pullRequest.reviewThreads
if ($page.nodes) {
$threads += $page.nodes
}

$after = $page.pageInfo.endCursor
} while ($page.pageInfo.hasNextPage)

$commentQuery = @'
query($threadId:ID!, $after:String) {
node(id:$threadId) {
... on PullRequestReviewThread {
comments(first:100, after:$after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
databaseId
body
author {
login
__typename
}
createdAt
updatedAt
url
path
diffHunk
outdated
pullRequestReview {
id
state
submittedAt
commit {
oid
}
}
}
}
}
}
}
'@

foreach ($thread in $threads) {
$commentAfter = $thread.comments.pageInfo.endCursor
while ($thread.comments.pageInfo.hasNextPage) {
$commentArgs = @(
"api"
)
if (-not [string]::IsNullOrWhiteSpace($Hostname)) {
$commentArgs += @("--hostname", $Hostname)
}
$commentArgs += @(
"graphql",
"-f", "threadId=$($thread.id)",
"-f", "query=$commentQuery"
)
if ($commentAfter) {
$commentArgs += @("-f", "after=$commentAfter")
}

$commentResult = Invoke-GhJson $commentArgs
$commentPage = $commentResult.data.node.comments
if ($commentPage.nodes) {
$thread.comments.nodes = @($thread.comments.nodes) + @($commentPage.nodes)
}

$thread.comments.pageInfo = $commentPage.pageInfo
$commentAfter = $commentPage.pageInfo.endCursor
}
}

$unresolvedThreads = @($threads | Where-Object { -not $_.isResolved })
$selectedThreads = if ($All) { @($threads) } else { $unresolvedThreads }

[pscustomobject]@{
repository = $Repository
pullRequest = $pr
unresolvedCount = $unresolvedThreads.Count
threads = $selectedThreads
} | ConvertTo-Json -Depth 100
137 changes: 137 additions & 0 deletions .agents/skills/finish-pr/scripts/reply-to-review-thread.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
[CmdletBinding(DefaultParameterSetName = "Body")]
param(
[Parameter(Mandatory = $true)]
[string]$ThreadId,

[string]$Hostname,

[Parameter(Mandatory = $true, ParameterSetName = "Body")]
[string]$Body,

[Parameter(Mandatory = $true, ParameterSetName = "File")]
[string]$BodyFile
)

$ErrorActionPreference = "Stop"

if ($PSCmdlet.ParameterSetName -eq "File") {
$Body = Get-Content -Raw -LiteralPath $BodyFile
}

function Invoke-GhJson {
param([Parameter(Mandatory = $true)][string[]]$GhArgs)

$args = @("api")
if (-not [string]::IsNullOrWhiteSpace($Hostname)) {
$args += @("--hostname", $Hostname)
}
$args += $GhArgs

$output = & gh @args 2>&1
if ($LASTEXITCODE -ne 0) {
throw ($output -join [Environment]::NewLine)
}

$json = $output -join [Environment]::NewLine
if ([string]::IsNullOrWhiteSpace($json)) {
throw "GitHub returned an empty JSON response."
}

return $json | ConvertFrom-Json -Depth 100
}

function Invoke-GhGraphQl {
param(
[Parameter(Mandatory = $true)][string]$Query,
[Parameter(Mandatory = $true)][hashtable]$Variables
)

$ghArgs = @("graphql", "-f", "query=$Query")
foreach ($entry in $Variables.GetEnumerator()) {
$ghArgs += @("-f", "$($entry.Key)=$($entry.Value)")
}

return Invoke-GhJson -GhArgs $ghArgs
}

$threadContextQuery = @'
query($threadId:ID!) {
node(id:$threadId) {
... on PullRequestReviewThread {
pullRequest {
number
repository {
nameWithOwner
}
}
comments(first:1) {
nodes {
databaseId
}
}
}
}
}
'@

$threadContext = Invoke-GhGraphQl -Query $threadContextQuery -Variables @{
threadId = $ThreadId
}
$thread = $threadContext.data.node
$pullRequest = $thread.pullRequest
$rootComment = @($thread.comments.nodes) | Select-Object -First 1

if ($null -eq $pullRequest -or
[string]::IsNullOrWhiteSpace([string]$pullRequest.repository.nameWithOwner) -or
[int]$pullRequest.number -le 0 -or
[long]$rootComment.databaseId -le 0) {
throw "GitHub returned no verifiable pull request context for review thread $ThreadId."
}

$reply = Invoke-GhJson -GhArgs @(
"-X", "POST",
"repos/$($pullRequest.repository.nameWithOwner)/pulls/$($pullRequest.number)/comments/$($rootComment.databaseId)/replies",
"-f", "body=$Body"
)

if ([string]::IsNullOrWhiteSpace([string]$reply.node_id)) {
throw "GitHub created no verifiable pull request review comment."
}

$verifyQuery = @'
query($commentId:ID!) {
node(id:$commentId) {
... on PullRequestReviewComment {
id
url
pullRequestReview {
id
state
submittedAt
}
}
}
}
'@

$verification = Invoke-GhGraphQl -Query $verifyQuery -Variables @{
commentId = $reply.node_id
}
$verifiedComment = $verification.data.node

if ($null -eq $verifiedComment -or
$null -eq $verifiedComment.pullRequestReview -or
$verifiedComment.pullRequestReview.state -eq "PENDING" -or
$null -eq $verifiedComment.pullRequestReview.submittedAt) {
throw "Review reply $($reply.node_id) is still pending or could not be verified as submitted."
}

[pscustomobject]@{
comment = [pscustomobject]@{
id = $verifiedComment.id
url = $verifiedComment.url
}
review = $verifiedComment.pullRequestReview
submittedPendingReview = $false
verifiedSubmitted = $true
} | ConvertTo-Json -Depth 10
Loading