From 259f14516e399a753122443a3894f458e2c035e4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:08:46 +0000 Subject: [PATCH 1/3] Update xunit-dotnet monorepo to v4 --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index df5e305..40a7225 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ - - + + \ No newline at end of file From 7e35543a9f0185a0bdd2dbd5e817b9098fee4bf8 Mon Sep 17 00:00:00 2001 From: Matthew Parker Date: Sat, 22 Aug 2026 21:38:28 +0100 Subject: [PATCH 2/3] added default skills --- .agents/skills/finish-pr/SKILL.md | 432 ++++++++++++ .agents/skills/finish-pr/agents/openai.yaml | 4 + .../scripts/get-unresolved-pr-threads.ps1 | 232 +++++++ .../scripts/reply-to-review-thread.ps1 | 137 ++++ .../finish-pr/scripts/wait-for-pr-review.ps1 | 631 ++++++++++++++++++ .../tests/reply-to-review-thread.tests.ps1 | 57 ++ .../tests/wait-for-pr-review.tests.ps1 | 250 +++++++ .agents/skills/implement-task-linear/SKILL.md | 128 ++++ .../implement-task-linear/agents/openai.yaml | 5 + .agents/skills/test-changes/SKILL.md | 79 +++ .../skills/test-changes/agents/openai.yaml | 5 + .agents/skills/to-linear/SKILL.md | 115 ++++ .agents/skills/to-linear/agents/openai.yaml | 5 + skills-lock.json | 29 + 14 files changed, 2109 insertions(+) create mode 100644 .agents/skills/finish-pr/SKILL.md create mode 100644 .agents/skills/finish-pr/agents/openai.yaml create mode 100644 .agents/skills/finish-pr/scripts/get-unresolved-pr-threads.ps1 create mode 100644 .agents/skills/finish-pr/scripts/reply-to-review-thread.ps1 create mode 100644 .agents/skills/finish-pr/scripts/wait-for-pr-review.ps1 create mode 100644 .agents/skills/finish-pr/tests/reply-to-review-thread.tests.ps1 create mode 100644 .agents/skills/finish-pr/tests/wait-for-pr-review.tests.ps1 create mode 100644 .agents/skills/implement-task-linear/SKILL.md create mode 100644 .agents/skills/implement-task-linear/agents/openai.yaml create mode 100644 .agents/skills/test-changes/SKILL.md create mode 100644 .agents/skills/test-changes/agents/openai.yaml create mode 100644 .agents/skills/to-linear/SKILL.md create mode 100644 .agents/skills/to-linear/agents/openai.yaml create mode 100644 skills-lock.json diff --git a/.agents/skills/finish-pr/SKILL.md b/.agents/skills/finish-pr/SKILL.md new file mode 100644 index 0000000..4d133ed --- /dev/null +++ b/.agents/skills/finish-pr/SKILL.md @@ -0,0 +1,432 @@ +--- +name: finish-pr +description: Finish the GitHub pull request attached to the current branch; resolve merge conflicts, diagnose and fix failed CI checks, action review feedback from every source without duplicating replies that are awaiting a reviewer response, push focused commits, and continue through Gitar review until its current-HEAD Core signals show a completed clean review. Use whenever the user asks to finish, complete, ready, resolve, fix, or address feedback/CI/conflicts on the current PR. +--- + +# Finish PR + +Bring the pull request attached to the current branch to a genuinely ready state. + +Requires Git 2.38+, GitHub CLI (`gh`) authenticated for the repository, and PowerShell 7 (`pwsh`) for bundled helpers. + +## Definition of done + +Finish only when all of these are true for the current PR HEAD: + +- GitHub and a local merge probe show no merge conflicts. +- No CI check is failing. Pending checks may still be running, but never describe the PR as fully ready while a required check is pending. +- Every unresolved review thread is in one of these states: + - its latest unaddressed feedback has been actioned and the agent has replied with the result; or + - the latest relevant comment is the agent's response and no reviewer has replied afterwards, so the thread is awaiting review and needs no duplicate work. +- No reply created by this run remains in a pending GitHub review. +- No review thread's resolution state was changed by this run. +- The exact current HEAD has a successful completed `Gitar` check, Gitar's dashboard Code Review verdict is `Approved`, and no later Gitar or other reviewer feedback is left unaddressed. + +This completion rule uses Gitar Core only. Never require Gitar auto-approval, a GitHub approving review, merge blocking, auto-apply, or any other Pro signal. Treat `Approved with Suggestions`, `Changes Requested`, `Blocked`, and `Needs Review` as non-terminal feedback states even if the `Gitar` check itself succeeds. + +An unresolved thread is not automatically unfinished. Reviewers own resolution state; the conversation order determines whether the agent currently owes action. + +## Operating rules + +- Use the conversation history as first-class task context. The skill is often invoked after implementation, so recover the user's intent, earlier decisions, tradeoffs, verification, known limitations, and reasons for the current design before judging PR feedback. +- Read every applicable `AGENTS.md` plus repository-native requirements and design documents implicated by the PR. Discover these from the repository and PR; never assume a particular task directory, branch naming scheme, language, build system, or hosting provider. +- Treat unresolved review threads as the authoritative inline-feedback list. Also inspect PR-level reviews and issue comments for standalone actionable feedback. +- Preserve unrelated worktree changes. Commit only changes made during this run. +- Resolve conflicts before failed checks, and failed checks before review feedback. Later evidence may require revisiting an earlier phase. +- Prefer the smallest correct change. Add focused tests for behavioural or regression-prone fixes. +- Use exactly one focused, unsquashed commit per action-required PR feedback unit whose disposition produces a change: one review thread or one standalone PR-level feedback item. Never combine multiple units into one commit, even when they are related. A justified disagreement requires a reply but no commit. +- Never rebase, force-push, merge the pull request on GitHub, close, approve, or mark the PR ready for review unless the user explicitly requested that separate action. The conflict-resolution workflow may merge the latest base commit into the PR branch. +- Never resolve or unresolve a review thread. Do not call `resolveReviewThread`, `unresolveReviewThread`, or an equivalent. +- Reply directly to review threads, one at a time. Never create replies concurrently. +- Continue autonomously through new feedback from Gitar and every other source after pushes, within the convergence bounds below. +- Never ask Gitar to apply or commit a fix. Do not use `gitar fix`, one-click apply, or `gitar auto-apply:on`. This agent owns every code change. + +## 1. Establish state and intent + +1. Locate the repository root and read applicable instructions. +2. Inspect: + + ```powershell + git status --short + git rev-parse --abbrev-ref HEAD + git rev-parse HEAD + gh --version + gh auth status + gh pr view --json number,title,url,body,author,headRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,baseRefName,baseRefOid,mergeable,mergeStateStatus,reviews,comments + ``` + +3. Record the starting SHA and every existing changed path, separating staged, unstaged, and untracked changes. Never include pre-existing changes in this run's commits. + A feedback or CI fix must not touch a path that had any pre-existing staged, unstaged, or untracked change. Stop and ask the user before editing that path; partial staging plus `git commit --only` does not preserve same-file hunk isolation. +4. If the current branch has no PR, inspect `gh pr status`. Switch or check out a PR only when the mapping is unambiguous and local changes are safe; otherwise ask the user. +5. Reconstruct the intended change from, in priority order: + - explicit user instructions and this conversation; + - applicable repository instructions; + - PR title, body, commits, and diff; + - linked issue/spec/design documents; + - surrounding code, tests, and conventions. +6. Inspect the complete PR diff before evaluating conflicts, CI, or feedback. +7. Identify the Gitar integration from exact case-insensitive `Gitar` check runs whose GitHub App slug is Gitar-marked, plus the Gitar-authored dashboard Code Review comment. Treat the exact-HEAD check as the processing/completion boundary and the dashboard's Code Review verdict as the review result. Never infer completion from reactions or require a GitHub approval review. +8. Resolve the authenticated GitHub viewer login. Treat comments from that login, or another agent login established unambiguously by the conversation/PR history, as agent responses. +9. Resolve the base and head repositories independently from PR metadata: + - derive the base repository from the PR URL; + - use `headRepository.nameWithOwner` for the head repository; + - prefer existing configured remotes that match those exact repositories; + - otherwise use GitHub-provided clone URLs and configure Git to use the authenticated `gh` credential helper; + - retain the exact `headRefName`. + + ```powershell + $pr = gh pr view --json number,url,headRefName,headRefOid,headRepository,headRepositoryOwner | + ConvertFrom-Json + $prNumber = [int]$pr.number + $prUri = [uri]$pr.url + $pathSegments = $prUri.AbsolutePath.Trim("/").Split("/") + $githubHostname = $prUri.Authority + $baseRepositoryName = "$($pathSegments[0])/$($pathSegments[1])" + $baseRepository = "$githubHostname/$baseRepositoryName" + $baseMetadata = gh api --hostname $githubHostname "repos/$baseRepositoryName" | + ConvertFrom-Json + $headMetadata = gh api --hostname $githubHostname "repos/$($pr.headRepository.nameWithOwner)" | + ConvertFrom-Json + gh auth setup-git --hostname $githubHostname + $baseFetchUrl = [string]$baseMetadata.clone_url + $headPushUrl = [string]$headMetadata.clone_url + ``` + + GitHub's `clone_url` preserves the server authority, including non-default ports, while `gh auth setup-git` makes HTTPS Git operations use the authenticated CLI identity. A verified matching SSH/configured remote may be used instead. Stop if authentication, repository identity, or the head repository is unavailable or ambiguous. Never assume `origin` points to either side of a fork-based PR. +10. Align the checkout with the exact PR head before making any changes: + + ```powershell + git fetch $headPushUrl $pr.headRefName + $remoteHeadSha = (git rev-parse FETCH_HEAD).Trim() + $lastObservedPrHeadSha = $remoteHeadSha + $localHeadSha = (git rev-parse HEAD).Trim() + git merge-base --is-ancestor $remoteHeadSha $localHeadSha + $localContainsRemoteHead = $LASTEXITCODE -eq 0 + + if ($localContainsRemoteHead -and $localHeadSha -ne $remoteHeadSha) { + $initialLocalAheadCommits = git log --oneline "$remoteHeadSha..$localHeadSha" + # Validate and record every commit against the recovered user intent and full PR diff. + # Stop for confirmation if any commit is unrelated, unfinished, or ambiguous. + } + + if (-not $localContainsRemoteHead) { + git merge-base --is-ancestor $localHeadSha $remoteHeadSha + $canFastForward = $LASTEXITCODE -eq 0 -and -not (git status --porcelain) + if (-not $canFastForward) { + throw "Local HEAD is stale or diverged from the PR head; stop before mutation." + } + + git merge --ff-only $remoteHeadSha + } + ``` + + Continue only when local `HEAD` matches the fetched PR head, is safely fast-forwarded to it, or every initially local-ahead commit was explicitly validated as intended PR work from conversation and repository evidence. Record those commits as pre-existing push scope; stop for confirmation on any uncertainty. Never silently publish local-ahead commits. +11. Create one unique state directory outside the repository and retain it for the full run: + + ```powershell + $runStateDirectory = Join-Path ([IO.Path]::GetTempPath()) ( + "finish-pr-{0}-{1}" -f $prNumber, [guid]::NewGuid().ToString("N") + ) + New-Item -ItemType Directory -Path $runStateDirectory | Out-Null + ``` + + Store every baseline, snapshot, and watcher-state file for this run inside this directory so concurrent runs cannot overwrite one another. + +## 2. Resolve merge conflicts + +Run this before CI or feedback: + +1. Refresh PR/base metadata and perform a non-mutating local probe: + + ```powershell + $pr = gh pr view $prNumber --repo $baseRepository --json number,url,baseRefName,baseRefOid,headRefName,headRefOid,headRepository,headRepositoryOwner,mergeable,mergeStateStatus | + ConvertFrom-Json + git fetch $baseFetchUrl $pr.baseRefName + $baseCommit = (git rev-parse FETCH_HEAD).Trim() + git merge-tree --write-tree --messages HEAD $baseCommit + ``` + +2. Investigate locally when GitHub reports `CONFLICTING`, `DIRTY`, or `UNKNOWN`, or when the probe reports conflicts. +3. If conflicts exist, check the initial worktree baseline before merging. A normal merge requires a clean index: if any pre-existing staged change remains, stop before mutation and tell the user to commit or stash it, or request explicit permission to isolate and restore the index. Do not use `--autostash` on user work without that permission. + + If the index is clean and unrelated local changes are safe, merge the latest base into the PR branch: + + ```powershell + git merge --no-ff $baseCommit + ``` + +4. Resolve each conflict using the recovered task intent, repository requirements, adjacent code, and tests. Do not mechanically prefer either side. +5. Run focused verification, stage only the resolution, and commit it. Record the conflict summary, commit SHA, and checks run. +6. If the merge is clean, keep any Git-created merge commit but do not create an extra empty commit. + +## 3. Fix failed CI + +1. Inspect all checks: + + ```powershell + gh pr checks $prNumber --repo $baseRepository --json bucket,completedAt,description,event,link,name,startedAt,state,workflow + gh pr checks $prNumber --repo $baseRepository --required --json bucket,completedAt,description,event,link,name,startedAt,state,workflow + ``` + + Use the second query to distinguish required checks from optional checks. Treat “no required checks reported” as an empty required set, not a failure. + +2. For each failure, retrieve the actual logs before editing: + + ```powershell + gh run list --repo $baseRepository --branch $pr.headRefName --commit $pr.headRefOid --json databaseId,name,workflowName,status,conclusion,url,headSha,event,createdAt -L 50 + gh run view --repo $baseRepository --json name,status,conclusion,jobs,url + gh run view --repo $baseRepository --log-failed + ``` + +3. For non-Actions checks, inspect the provider link or available check details. +4. Fix the root cause, not merely the symptom. Work from the clearest upstream failure outward because one failure may cascade into others. +5. Run the closest local equivalent, inspect the initial worktree baseline, and stage only that fix. Commit with an exact pathspec so pre-existing staged changes remain outside the CI commit: + + ```powershell + git commit --only -m "fix(ci): address " -- + ``` + + If a CI fix path had pre-existing staged, unstaged, or untracked changes, stop before editing it. Record the check, cause, SHA, and verification. +6. If a failure is external, flaky, permission-related, or not repository-fixable, capture evidence. Retry only when safe and supported; do not change code to appease an unrelated failure. + +## 4. Fetch and classify feedback + +Resolve the loaded skill's directory, then use its bundled helpers by absolute path; do not assume the skill lives inside the target repository. + +Capture every thread's read-only resolution baseline outside the repository: + +```powershell +$threadBaseline = Join-Path $runStateDirectory "thread-resolution.json" +$threadSnapshot = Join-Path $runStateDirectory "unresolved-threads.json" +pwsh /scripts/get-unresolved-pr-threads.ps1 -PrNumber $prNumber -Repository $baseRepository -Hostname $githubHostname -All | + Set-Content -Encoding utf8 $threadBaseline + +pwsh /scripts/get-unresolved-pr-threads.ps1 -PrNumber $prNumber -Repository $baseRepository -Hostname $githubHostname | + Set-Content -Encoding utf8 $threadSnapshot +``` + +For each unresolved thread, read all paginated comments in chronological order and classify it: + +- **Awaiting reviewer:** the latest relevant comment is an agent response in a submitted review (`pullRequestReview.state != PENDING` with non-null `submittedAt`) and nobody has replied later. Do nothing. Do not post a reminder, repeat the fix, or duplicate the response. +- **Action required:** there is reviewer feedback after the agent's latest submitted response, the agent has never responded, or its latest response exists only in a pending review. +- **Superseded/non-actionable:** the later conversation explicitly withdraws, answers, or supersedes the point. Reply only if the thread still needs an agent acknowledgement; avoid duplicating an existing agent response. + +Within action-required threads, identify each distinct feedback item. Judge it against the user's intent, conversation history, repository rules, linked requirements, PR scope, current code, conventions, and tests. The thread is the commit boundary: group all work required by one thread into that thread's single commit, but never include another thread's work. + +Classify actionable PR-level review bodies and issue comments with the same rules. Each standalone feedback item is its own commit boundary; never combine it with a review thread or another standalone item. Track it by feedback ID and permalink because it has no review-thread ID. + +- Agree when it identifies a real bug, missed requirement, broken invariant, missing test, misleading behaviour, or scoped maintainability problem. +- Disagree when it conflicts with requirements, established intent, repository invariants, or would produce a worse/out-of-scope design. +- When uncertain, make a small scoped correctness fix if evidence supports it. Otherwise explain the uncertainty and why no change was made. + +Do not skip outdated unresolved threads; determine whether their feedback still applies to current code. + +## 5. Fix and reply + +For each action-required review thread or standalone feedback item: + +1. Make the smallest complete fix for every actionable item in that thread, with focused tests. +2. Run the narrowest meaningful verification. +3. Inspect and stage only files for that item: + + ```powershell + git status --short + git diff + git add + git diff --cached + ``` + + Keep unrelated staged changes in other paths staged. Stop before editing any fix path that had a pre-existing staged, unstaged, or untracked change; never rely on partial staging followed by `git commit --only` for same-file isolation. + +4. If the disposition produces a change, create exactly one commit for the feedback unit before moving to the next unit: + + ```powershell + git commit --only -m "fix(pr): address " -- + ``` + + Do not amend, squash, or combine feedback commits. If an earlier unit's change completely satisfies a later agreed unit and no distinct file change remains, create an explicit traceability commit with `--allow-empty` for that later unit rather than merging their commit history. Do not create a commit for a justified disagreement. + +5. For review-thread feedback, reply directly to the thread after evaluating it and creating any relevant commit, subject to the Gitar deferral below: + + ```powershell + $body = @" + Agreed. I fixed this in commit by . + + Verification: . + "@ + pwsh /scripts/reply-to-review-thread.ps1 -ThreadId "" -Hostname $githubHostname -Body $body + ``` + + For standalone feedback, post a PR-level reply that links the exact feedback permalink: + + ```powershell + $body = @" + Regarding [this feedback](): agreed. I fixed this in commit by . + + Verification: . + "@ + gh pr comment $prNumber --repo $baseRepository --body $body + ``` + + Use the same PR-level path for a standalone justified disagreement, retaining the feedback permalink and the disagreement wording below. + + For a justified disagreement: + + ```text + I don't think this change is correct for this PR. + + Reason: . + + No code change made. + ``` + + If any Gitar-authored feedback disposition created a commit that has not been pushed yet, defer its thread or PR-level reply until immediately after the batch push. Gitar cannot verify a local-only SHA; replying before it can see the commit may cause a misleading follow-up. Prefix a PR-level Gitar reply with `Gitar,` so the dashboard feedback is processed; never ask it to apply the fix. A Gitar disagreement has no commit dependency and may be replied to immediately. Keep all replies serial. + +6. Record the one-to-one feedback-unit ID → commit SHA mapping for changed dispositions, plus every disposition, verification, and returned comment ID. Use the thread ID for review threads and the feedback ID for standalone items. Record `no commit — disagreement` for justified disagreements. + +The reply helper uses GitHub's single-comment reply endpoint so it never submits or modifies a shared pending review. It then verifies `state != PENDING` plus a non-null `submittedAt`. A helper failure is blocking; a returned comment URL alone is not proof of submission. + +After all replies: + +1. Re-fetch all threads with `-All`, plus PR-level reviews and issue comments. +2. Verify every reply created in this run belongs to a submitted review. +3. Verify the authenticated user has no pending review on the PR, including reviews created before this run. +4. For every thread ID present in the baseline, compare its `isResolved` value with the current value. Baseline resolution states must be unchanged; report external changes and never mutate them back. New thread IDs are expected during review convergence: classify them as additional feedback rather than treating their existence as a resolution mutation. +5. If new action-required review threads or standalone feedback items appeared during the batch, action each in its own commit and repeat the audit. Push only once the currently visible feedback set has been fully actioned or is awaiting reviewer response. Gitar threads with a deferred local-commit reply count as actioned for this pre-push audit, but the reply remains mandatory immediately after push. + +## 6. Push and converge with Gitar + +1. Confirm the worktree contains no uncommitted changes created by this run. Do not push individual thread commits as they are created; batch-push all unsquashed thread commits only after the feedback audit is clear for the time being. +2. Review commits after the starting SHA and every validated commit that was already local-ahead at invocation, then fetch the exact PR head before pushing: + + ```powershell + git log --oneline ..HEAD + git fetch $headPushUrl $pr.headRefName + $remoteHeadSha = (git rev-parse FETCH_HEAD).Trim() + $localHeadSha = (git rev-parse HEAD).Trim() + $currentPrHeadSha = (gh pr view $prNumber --repo $baseRepository --json headRefOid --jq .headRefOid).Trim() + $pushRequired = $localHeadSha -ne $remoteHeadSha + git status --short --branch + ``` + + Stop if `$remoteHeadSha` and `$currentPrHeadSha` differ, or if either differs from `$lastObservedPrHeadSha`. Never overwrite `$lastObservedPrHeadSha` with an unexpected remote value. Use `$pushRequired`, not the starting-SHA commit range, to decide whether the PR head needs a push; the range is reporting context only. + +3. If `$pushRequired`, increment `$reviewRound` and capture a review baseline immediately before pushing: + + ```powershell + $reviewRound++ + $reviewState = Join-Path $runStateDirectory ("review-round-{0}.json" -f $reviewRound) + pwsh /scripts/wait-for-pr-review.ps1 ` + -CaptureBaseline ` + -StatePath $reviewState ` + -PrNumber $prNumber ` + -Repository $baseRepository ` + -Hostname $githubHostname + ``` + +4. Push without force and record the exact HEAD: + + ```powershell + $reviewRequestedAt = [DateTimeOffset]::UtcNow + git push $headPushUrl "HEAD:refs/heads/$($pr.headRefName)" + $expectedHeadSha = git rev-parse HEAD + $lastObservedPrHeadSha = $expectedHeadSha + ``` + + Immediately after the push, post each deferred Gitar thread or PR-level reply serially with its now-visible commit SHA and verification result. Verify thread replies are submitted, then refresh feedback once before waiting for Gitar. + +5. After pushing, allow approximately 60 seconds for the exact-HEAD `Gitar` check to appear: + + ```powershell + pwsh /scripts/wait-for-pr-review.ps1 ` + -Wait ` + -StatePath $reviewState ` + -ExpectedHeadSha $expectedHeadSha ` + -ReviewRequestedAt $reviewRequestedAt ` + -ReviewStartGraceSeconds 60 ` + -TimeoutMinutes 25 ` + -PollSeconds 10 + ``` + + If this returns `review_not_started`, and only then, request review once for that pushed HEAD: + + ```powershell + gh pr comment $prNumber --repo $baseRepository --body "gitar review" + ``` + + Do not post the fallback comment when an exact-HEAD Gitar check appeared during the grace period, even if it is still queued or processing. + +6. If the grace-period watcher returned `review_not_started`, resume the bundled watcher against the same baseline: + + ```powershell + pwsh /scripts/wait-for-pr-review.ps1 ` + -Wait ` + -StatePath $reviewState ` + -ExpectedHeadSha $expectedHeadSha ` + -ReviewRequestedAt $reviewRequestedAt ` + -TimeoutMinutes 25 ` + -PollSeconds 20 + ``` + + Run it as a long-lived tool call. While it runs, use only the environment's wait mechanism and remain silent unless the user interrupts. The watcher keeps repeated polling out of model context. + +7. Handle its terminal result: + - `feedback`: fetch all feedback for context, but action only IDs in `newFeedback`. This may include inline threads, PR-level feedback from any reviewer, or Gitar's dashboard when its verdict is `Approved with Suggestions`, `Changes Requested`, `Blocked`, or `Needs Review`. If a new comment extends an old unresolved thread, read the full thread and handle only feedback after the last agent response. + - `approved`: the exact-HEAD Gitar check completed successfully and the fresh dashboard verdict is `Approved`. Re-fetch checks, dashboard, PR-level feedback, and threads once; finish only if the full definition of done still holds. + - `gitar_failed`: inspect the Gitar check and dashboard details. Treat provider/integration failure as a blocker unless repository evidence gives a scoped fix; never describe the PR as reviewed successfully. + - `review_not_started`: post the single fallback `gitar review` comment, then resume step 6. + - `timeout`: report that Gitar did not reach a terminal state; do not claim readiness. + - `head_changed`: fetch and inspect the new state. Stop when another actor's push makes continued mutation unsafe. + - `pr_closed`: stop and report the PR state. + +8. For new feedback, repeat fix → verify → commit → serial reply → audit → baseline → push → automatic-review grace period → wait. + +If no push is needed, never post `gitar review`: the fallback is only permitted after this run pushes a new HEAD. Capture the current state and wait read-only. A completed successful `Gitar` check on the exact current HEAD plus an `Approved` dashboard verdict is sufficient; no Pro approval signal is required. Otherwise wait for an already-running automatic review and stop on timeout without posting a trigger. + +```powershell +$expectedHeadSha = (git rev-parse HEAD).Trim() +$reviewRound++ +$reviewState = Join-Path $runStateDirectory ("review-round-{0}.json" -f $reviewRound) +pwsh /scripts/wait-for-pr-review.ps1 ` + -CaptureBaseline ` + -StatePath $reviewState ` + -PrNumber $prNumber ` + -Repository $baseRepository ` + -Hostname $githubHostname +pwsh /scripts/wait-for-pr-review.ps1 ` + -Wait ` + -StatePath $reviewState ` + -ExpectedHeadSha $expectedHeadSha ` + -ReviewRequestedAt ([DateTimeOffset]::MinValue) ` + -TimeoutMinutes 25 ` + -PollSeconds 20 +``` + +The watcher verifies that Gitar's check belongs to the exact expected SHA. After a push, it also requires the dashboard comment to have changed after the baseline and in the same processing window before accepting its verdict, because Gitar edits one persistent dashboard comment in place. Never use the fallback comment without a preceding push from this run. + +Bound convergence to five pushed review rounds or two hours overall. Stop earlier for a clean Gitar result, timeout, closure, unexpected head movement, or a genuine blocker. + +## Final audit and response + +Re-fetch rather than relying on cached state: + +- PR head, mergeability, and base; +- latest base commit and a fresh local `git merge-tree --write-tree --messages HEAD ` conflict probe; +- all checks; +- required checks queried separately with `gh pr checks --required`; +- the exact-HEAD Gitar check and Gitar dashboard Code Review verdict; +- PR-level reviews/comments; +- all review threads and reply submission states; +- local/remote branch state and worktree. + +Report concisely: + +- PR number and URL; +- conflict and CI outcome, with commits; +- thread counts: actioned, awaiting reviewer, and disagreed; +- fixes, focused verification, and commit SHAs; +- push result and Gitar review rounds; +- terminal Gitar Core status for current HEAD: successful completed check plus `Approved` dashboard verdict; +- pending review replies: `0`; +- review-thread resolution mutations: `0`, with baseline audit result; +- any blocker or required check still pending. diff --git a/.agents/skills/finish-pr/agents/openai.yaml b/.agents/skills/finish-pr/agents/openai.yaml new file mode 100644 index 0000000..cfe14a2 --- /dev/null +++ b/.agents/skills/finish-pr/agents/openai.yaml @@ -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." diff --git a/.agents/skills/finish-pr/scripts/get-unresolved-pr-threads.ps1 b/.agents/skills/finish-pr/scripts/get-unresolved-pr-threads.ps1 new file mode 100644 index 0000000..527ed45 --- /dev/null +++ b/.agents/skills/finish-pr/scripts/get-unresolved-pr-threads.ps1 @@ -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 diff --git a/.agents/skills/finish-pr/scripts/reply-to-review-thread.ps1 b/.agents/skills/finish-pr/scripts/reply-to-review-thread.ps1 new file mode 100644 index 0000000..4c9b2ac --- /dev/null +++ b/.agents/skills/finish-pr/scripts/reply-to-review-thread.ps1 @@ -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 diff --git a/.agents/skills/finish-pr/scripts/wait-for-pr-review.ps1 b/.agents/skills/finish-pr/scripts/wait-for-pr-review.ps1 new file mode 100644 index 0000000..9eb621a --- /dev/null +++ b/.agents/skills/finish-pr/scripts/wait-for-pr-review.ps1 @@ -0,0 +1,631 @@ +[CmdletBinding(DefaultParameterSetName = "Wait")] +param( + [Parameter(Mandatory = $true, ParameterSetName = "Capture")] + [switch]$CaptureBaseline, + + [Parameter(Mandatory = $true, ParameterSetName = "Wait")] + [switch]$Wait, + + [Parameter(Mandatory = $true)] + [string]$StatePath, + + [int]$PrNumber, + + [Parameter(ParameterSetName = "Capture")] + [string]$Repository, + + [Parameter(ParameterSetName = "Capture")] + [string]$Hostname, + + [Parameter(Mandatory = $true, ParameterSetName = "Wait")] + [string]$ExpectedHeadSha, + + [Parameter(Mandatory = $true, ParameterSetName = "Wait")] + [DateTimeOffset]$ReviewRequestedAt, + + [Parameter(ParameterSetName = "Wait")] + [ValidateRange(1, 120)] + [int]$TimeoutMinutes = 25, + + [Parameter(ParameterSetName = "Wait")] + [ValidateRange(5, 300)] + [int]$PollSeconds = 20, + + [Parameter(ParameterSetName = "Wait")] + [ValidateRange(0, 300)] + [int]$ReviewStartGraceSeconds = 0 +) + +$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 +} + +function Normalize-Login { + param([AllowNull()][string]$Login) + + if ([string]::IsNullOrWhiteSpace($Login)) { + return "" + } + + return $Login.Trim().ToLowerInvariant() -replace '\[bot\]$', '' +} + +function Test-GitarActor { + param( + [AllowNull()][string]$Login, + [AllowNull()][string]$AuthorType + ) + + $normalizedLogin = Normalize-Login $Login + return $normalizedLogin -match '(?i)(?:^|[-_])gitar(?:$|[-_])' -and + $AuthorType -in @("App", "Bot", "Organization") +} + +function Expand-PaginatedItems { + param([AllowNull()]$Pages) + + foreach ($page in @($Pages)) { + foreach ($item in @($page)) { + $item + } + } +} + +function Resolve-RepositoryRouting { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [string]$Hostname + ) + + $parts = $Repository.Split("/") + if ($parts.Count -eq 3) { + if ([string]::IsNullOrWhiteSpace($Hostname)) { + $Hostname = $parts[0] + } elseif ($Hostname -ne $parts[0]) { + throw "-Hostname does not match the hostname in -Repository." + } + $apiRepository = "$($parts[1])/$($parts[2])" + } elseif ($parts.Count -eq 2) { + $apiRepository = $Repository + } else { + throw "-Repository must use the owner/name or hostname/owner/name format." + } + + $selector = if ([string]::IsNullOrWhiteSpace($Hostname)) { + $apiRepository + } else { + "$Hostname/$apiRepository" + } + + return [pscustomobject]@{ + hostname = $Hostname + apiRepository = $apiRepository + selector = $selector + } +} + +function Test-GitarDashboardBody { + param([AllowNull()][string]$Body) + + if ([string]::IsNullOrWhiteSpace($Body)) { + return $false + } + + return $Body -match '(?is)\s*Code Review\s*|^\s*#{1,6}\s+Code Review\b' +} + +function Get-GitarVerdict { + param([AllowNull()][string]$Body) + + if (-not (Test-GitarDashboardBody $Body)) { + return "unknown" + } + + if ($Body -notmatch '(?is)\s*Code Review\s*\s*\s*(?[^<]+?)\s*') { + return "unknown" + } + + $verdict = $Matches.verdict + + if ($verdict -match '(?i)Approved\s+with\s+Suggestions') { + return "approved_with_suggestions" + } + if ($verdict -match '(?i)Changes\s+Requested') { + return "changes_requested" + } + if ($verdict -match '(?i)Needs?\s+Review') { + return "needs_review" + } + if ($verdict -match '(?i)Blocked') { + return "blocked" + } + if ($verdict -match '(?i)Approved') { + return "approved" + } + if ($verdict -match '(?i)Processing|In\s+Progress|Reviewing') { + return "processing" + } + + return "unknown" +} + +function Test-ActionableFeedbackItem { + param([Parameter(Mandatory = $true)]$Item) + + $body = [string]$Item.body + if ([string]::IsNullOrWhiteSpace($body)) { + return $false + } + + if ($Item.kind -eq "review") { + if ($Item.reviewState -in @("DISMISSED", "PENDING")) { + return $false + } + + if ((Test-GitarActor -Login $Item.authorLogin -AuthorType $Item.authorType) -and + $body.Trim() -match '(?i)^Gitar has auto-approved this PR\b') { + return $false + } + + if ($body -match '(?i)reviewed\s+\d+\s+out\s+of\s+\d+\s+changed files.*generated no new comments') { + return $false + } + } + + return $body.Trim() -notmatch '^(?:lgtm|looks good(?: to me)?|approved|acknowledged|thanks|thank you|done|👍)[.! ]*$' +} + +function Get-PrReviewSnapshot { + param( + [Parameter(Mandatory = $true)][string]$Repository, + [Parameter(Mandatory = $true)][int]$Number, + [string]$Hostname, + [ValidateRange(1, 3)][int]$Attempt = 1 + ) + + $routing = Resolve-RepositoryRouting -Repository $Repository -Hostname $Hostname + $pr = Invoke-GhJson @( + "pr", "view", $Number.ToString(), "--repo", $routing.selector, + "--json", "number,url,state,headRefOid" + ) + + $apiPrefix = @("api") + if (-not [string]::IsNullOrWhiteSpace($routing.hostname)) { + $apiPrefix += @("--hostname", $routing.hostname) + } + + $commentPages = Invoke-GhJson ($apiPrefix + @( + "repos/$($routing.apiRepository)/issues/$Number/comments", "--paginate", "--slurp" + )) + $comments = @(Expand-PaginatedItems $commentPages) + + $reviewPages = Invoke-GhJson ($apiPrefix + @( + "repos/$($routing.apiRepository)/pulls/$Number/reviews", "--paginate", "--slurp" + )) + $reviews = @(Expand-PaginatedItems $reviewPages) + + $threadScript = Join-Path $PSScriptRoot "get-unresolved-pr-threads.ps1" + $threadJson = & $threadScript ` + -PrNumber $Number ` + -Repository $routing.selector ` + -Hostname $routing.hostname ` + -All + if ($LASTEXITCODE -ne 0) { + throw "Failed to fetch pull request review threads." + } + $threadData = ($threadJson -join [Environment]::NewLine) | ConvertFrom-Json -Depth 100 + + $checkResponse = Invoke-GhJson ($apiPrefix + @( + "-H", "Accept: application/vnd.github+json", + "repos/$($routing.apiRepository)/commits/$($pr.headRefOid)/check-runs?filter=latest&per_page=100" + )) + $gitarChecks = @($checkResponse.check_runs | Where-Object { + $_.name -eq "Gitar" -and + ([string]$_.app.slug -match '(?i)(?:^|[-_])gitar(?:$|[-_])') + } | ForEach-Object { + [pscustomobject]@{ + id = [long]$_.id + name = [string]$_.name + appSlug = [string]$_.app.slug + status = [string]$_.status + conclusion = [string]$_.conclusion + startedAt = $_.started_at + completedAt = $_.completed_at + url = [string]$_.html_url + detailsUrl = [string]$_.details_url + headSha = [string]$_.head_sha + } + }) + + $feedbackItems = @() + foreach ($thread in @($threadData.threads)) { + if ([bool]$thread.isResolved) { + continue + } + + foreach ($comment in @($thread.comments.nodes)) { + $feedbackItems += [pscustomobject]@{ + id = [string]$comment.id + kind = "thread_comment" + authorLogin = [string]$comment.author.login + authorType = [string]$comment.author.__typename + body = [string]$comment.body + url = [string]$comment.url + createdAt = $comment.createdAt + updatedAt = $comment.updatedAt + threadId = [string]$thread.id + headSha = [string]$comment.pullRequestReview.commit.oid + isGitarDashboard = $false + } + } + } + + foreach ($comment in $comments) { + $isGitarDashboard = (Test-GitarActor -Login $comment.user.login -AuthorType $comment.user.type) -and + (Test-GitarDashboardBody -Body $comment.body) + $feedbackItems += [pscustomobject]@{ + id = [string]$comment.node_id + kind = "issue_comment" + authorLogin = [string]$comment.user.login + authorType = [string]$comment.user.type + body = [string]$comment.body + url = [string]$comment.html_url + createdAt = $comment.created_at + updatedAt = $comment.updated_at + threadId = $null + headSha = $null + isGitarDashboard = $isGitarDashboard + } + } + + foreach ($review in $reviews) { + $feedbackItems += [pscustomobject]@{ + id = [string]$review.node_id + kind = "review" + authorLogin = [string]$review.user.login + authorType = [string]$review.user.type + body = [string]$review.body + url = [string]$review.html_url + createdAt = $review.submitted_at + updatedAt = $review.submitted_at + reviewState = ([string]$review.state).ToUpperInvariant() + threadId = $null + headSha = [string]$review.commit_id + isGitarDashboard = $false + } + } + + $dashboard = @($feedbackItems | Where-Object { $_.isGitarDashboard } | + Sort-Object { [DateTimeOffset]$_.updatedAt } -Descending | + Select-Object -First 1) + $selectedDashboard = if ($dashboard.Count -eq 0) { $null } else { $dashboard[0] } + + $verifiedPr = Invoke-GhJson @( + "pr", "view", $Number.ToString(), "--repo", $routing.selector, + "--json", "headRefOid" + ) + if ($verifiedPr.headRefOid -ne $pr.headRefOid) { + if ($Attempt -ge 3) { + throw "Pull request HEAD changed repeatedly while collecting a review snapshot." + } + + return Get-PrReviewSnapshot ` + -Repository $Repository ` + -Number $Number ` + -Hostname $Hostname ` + -Attempt ($Attempt + 1) + } + + return [pscustomobject]@{ + pullRequest = [pscustomobject]@{ + number = [int]$pr.number + url = [string]$pr.url + state = [string]$pr.state + headSha = [string]$pr.headRefOid + } + feedbackItems = @($feedbackItems) + gitarChecks = @($gitarChecks) + gitarDashboard = $selectedDashboard + } +} + +function Get-LatestGitarCheck { + param([Parameter(Mandatory = $true)]$Snapshot) + + return @($Snapshot.gitarChecks | + Sort-Object { if ($_.startedAt) { [DateTimeOffset]$_.startedAt } else { [DateTimeOffset]::MinValue } } -Descending | + Select-Object -First 1) +} + +function Get-NewFeedback { + param( + [Parameter(Mandatory = $true)]$Baseline, + [Parameter(Mandatory = $true)]$Snapshot + ) + + $agentLogin = Normalize-Login ([string]$Baseline.agentLogin) + $seenVersions = @($Baseline.seenFeedbackVersions) + return @($Snapshot.feedbackItems | Where-Object { + if ($_.isGitarDashboard -or + (Normalize-Login $_.authorLogin) -eq $agentLogin -or + -not (Test-ActionableFeedbackItem $_)) { + return $false + } + + $seen = $seenVersions | Where-Object id -eq $_.id | Select-Object -First 1 + if ($null -eq $seen) { + return $true + } + + return [string]$_.updatedAt -ne [string]$seen.updatedAt -or + [string]$_.body -ne [string]$seen.body + }) +} + +function Test-DashboardFreshForCheck { + param( + [Parameter(Mandatory = $true)]$Baseline, + [AllowNull()]$Dashboard, + [AllowNull()]$Check, + [Parameter(Mandatory = $true)][string]$ExpectedSha + ) + + if ($null -eq $Dashboard -or $null -eq $Check) { + return $false + } + + if ([string]$Baseline.baselineHeadSha -eq $ExpectedSha) { + return $true + } + + $baselineDashboard = $Baseline.gitarDashboard + $changed = $null -eq $baselineDashboard -or + [string]$baselineDashboard.id -ne [string]$Dashboard.id -or + [string]$baselineDashboard.updatedAt -ne [string]$Dashboard.updatedAt -or + [string]$baselineDashboard.body -ne [string]$Dashboard.body + if (-not $changed) { + return $false + } + + if ([string]::IsNullOrWhiteSpace([string]$Dashboard.updatedAt) -or + [string]::IsNullOrWhiteSpace([string]$Check.startedAt)) { + return $true + } + + return [DateTimeOffset]$Dashboard.updatedAt -ge ([DateTimeOffset]$Check.startedAt).AddSeconds(-5) +} + +function Resolve-ReviewOutcome { + param( + [Parameter(Mandatory = $true)]$Baseline, + [Parameter(Mandatory = $true)]$Snapshot, + [Parameter(Mandatory = $true)][string]$ExpectedSha + ) + + if ($Snapshot.pullRequest.state -ne "OPEN") { + return [pscustomobject]@{ status = "pr_closed"; verdict = "unknown"; check = $null; newFeedback = @() } + } + + if ($Snapshot.pullRequest.headSha -ne $ExpectedSha) { + if ([string]$Snapshot.pullRequest.headSha -eq [string]$Baseline.baselineHeadSha) { + return [pscustomobject]@{ status = "waiting"; verdict = "unknown"; check = $null; newFeedback = @() } + } + + return [pscustomobject]@{ status = "head_changed"; verdict = "unknown"; check = $null; newFeedback = @() } + } + + $newFeedback = @(Get-NewFeedback -Baseline $Baseline -Snapshot $Snapshot) + if ($newFeedback.Count -gt 0) { + return [pscustomobject]@{ status = "feedback"; verdict = "unknown"; check = $null; newFeedback = $newFeedback } + } + + $checkArray = @(Get-LatestGitarCheck -Snapshot $Snapshot) + $check = if ($checkArray.Count -eq 0) { $null } else { $checkArray[0] } + if ($null -eq $check) { + return [pscustomobject]@{ status = "waiting"; verdict = "unknown"; check = $null; newFeedback = @() } + } + + if ($check.status -ne "completed") { + return [pscustomobject]@{ status = "processing"; verdict = "processing"; check = $check; newFeedback = @() } + } + + if ($check.conclusion -ne "success") { + return [pscustomobject]@{ status = "gitar_failed"; verdict = "unknown"; check = $check; newFeedback = @() } + } + + if (-not (Test-DashboardFreshForCheck -Baseline $Baseline -Dashboard $Snapshot.gitarDashboard -Check $check -ExpectedSha $ExpectedSha)) { + return [pscustomobject]@{ status = "waiting"; verdict = "unknown"; check = $check; newFeedback = @() } + } + + $verdict = Get-GitarVerdict -Body $Snapshot.gitarDashboard.body + if ($verdict -eq "approved") { + return [pscustomobject]@{ status = "approved"; verdict = $verdict; check = $check; newFeedback = @() } + } + + if ($verdict -in @("approved_with_suggestions", "changes_requested", "needs_review", "blocked")) { + $dashboardFeedback = [pscustomobject]@{ + id = [string]$Snapshot.gitarDashboard.id + kind = "gitar_dashboard" + authorLogin = [string]$Snapshot.gitarDashboard.authorLogin + authorType = [string]$Snapshot.gitarDashboard.authorType + body = [string]$Snapshot.gitarDashboard.body + url = [string]$Snapshot.gitarDashboard.url + createdAt = $Snapshot.gitarDashboard.createdAt + updatedAt = $Snapshot.gitarDashboard.updatedAt + threadId = $null + headSha = $ExpectedSha + verdict = $verdict + } + return [pscustomobject]@{ status = "feedback"; verdict = $verdict; check = $check; newFeedback = @($dashboardFeedback) } + } + + return [pscustomobject]@{ status = "processing"; verdict = $verdict; check = $check; newFeedback = @() } +} + +function Save-ReviewState { + param( + [Parameter(Mandatory = $true)]$State, + [Parameter(Mandatory = $true)][string]$Path + ) + + $parent = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + $State | ConvertTo-Json -Depth 100 | Set-Content -Encoding utf8 -LiteralPath $Path +} + +function Invoke-PrReviewWatcher { + if ($CaptureBaseline) { + if ([string]::IsNullOrWhiteSpace($Repository)) { + $repo = Invoke-GhJson @("repo", "view", "--json", "owner,name,url") + $Repository = "$($repo.owner.login)/$($repo.name)" + if ([string]::IsNullOrWhiteSpace($Hostname)) { + $Hostname = ([uri]$repo.url).Authority + } + } + + $routing = Resolve-RepositoryRouting -Repository $Repository -Hostname $Hostname + if ($PrNumber -le 0) { + $pr = Invoke-GhJson @( + "pr", "view", "--repo", $routing.selector, "--json", "number" + ) + $PrNumber = [int]$pr.number + } + + $viewerArgs = @("api") + if (-not [string]::IsNullOrWhiteSpace($routing.hostname)) { + $viewerArgs += @("--hostname", $routing.hostname) + } + $viewerArgs += @("user") + $viewer = Invoke-GhJson $viewerArgs + $snapshot = Get-PrReviewSnapshot ` + -Repository $routing.selector ` + -Number $PrNumber ` + -Hostname $routing.hostname + + $dashboardState = if ($null -eq $snapshot.gitarDashboard) { + $null + } else { + [pscustomobject]@{ + id = [string]$snapshot.gitarDashboard.id + body = [string]$snapshot.gitarDashboard.body + updatedAt = $snapshot.gitarDashboard.updatedAt + } + } + $state = [pscustomobject]@{ + repository = $routing.selector + hostname = $routing.hostname + prNumber = $PrNumber + agentLogin = Normalize-Login $viewer.login + baselineHeadSha = [string]$snapshot.pullRequest.headSha + capturedAt = [DateTimeOffset]::UtcNow + seenFeedbackVersions = @($snapshot.feedbackItems | ForEach-Object { + [pscustomobject]@{ + id = [string]$_.id + body = [string]$_.body + updatedAt = $_.updatedAt + } + }) + gitarDashboard = $dashboardState + } + Save-ReviewState -State $state -Path $StatePath + + [pscustomobject]@{ + status = "baseline_captured" + statePath = $StatePath + repository = $routing.selector + prNumber = $PrNumber + headSha = $state.baselineHeadSha + } | ConvertTo-Json -Depth 10 + return + } + + if (-not (Test-Path -LiteralPath $StatePath)) { + throw "Review baseline state does not exist: $StatePath" + } + + $state = Get-Content -Raw -LiteralPath $StatePath | ConvertFrom-Json -Depth 100 + $deadline = [DateTimeOffset]::UtcNow.AddMinutes($TimeoutMinutes) + $reviewStartDeadline = if ($ReviewStartGraceSeconds -gt 0) { + $start = if ($ReviewRequestedAt -eq [DateTimeOffset]::MinValue) { + [DateTimeOffset]::UtcNow + } else { + $ReviewRequestedAt + } + $start.AddSeconds($ReviewStartGraceSeconds) + } else { + [DateTimeOffset]::MaxValue + } + + while ([DateTimeOffset]::UtcNow -lt $deadline) { + $snapshot = Get-PrReviewSnapshot ` + -Repository $state.repository ` + -Number ([int]$state.prNumber) ` + -Hostname ([string]$state.hostname) + $outcome = Resolve-ReviewOutcome ` + -Baseline $state ` + -Snapshot $snapshot ` + -ExpectedSha $ExpectedHeadSha + + if ($outcome.status -in @("feedback", "approved", "gitar_failed", "head_changed", "pr_closed")) { + [pscustomobject]@{ + status = $outcome.status + repository = $state.repository + prNumber = $state.prNumber + expectedHeadSha = $ExpectedHeadSha + actualHeadSha = $snapshot.pullRequest.headSha + gitarVerdict = $outcome.verdict + gitarCheck = $outcome.check + newFeedback = @($outcome.newFeedback) + } | ConvertTo-Json -Depth 100 + return + } + + if ($ReviewStartGraceSeconds -gt 0 -and + [DateTimeOffset]::UtcNow -ge $reviewStartDeadline -and + $snapshot.pullRequest.headSha -eq $ExpectedHeadSha -and + $null -eq $outcome.check) { + [pscustomobject]@{ + status = "review_not_started" + repository = $state.repository + prNumber = $state.prNumber + expectedHeadSha = $ExpectedHeadSha + actualHeadSha = $snapshot.pullRequest.headSha + gitarVerdict = "unknown" + gitarCheck = $null + newFeedback = @() + } | ConvertTo-Json -Depth 20 + return + } + + Start-Sleep -Seconds $PollSeconds + } + + [pscustomobject]@{ + status = "timeout" + repository = $state.repository + prNumber = $state.prNumber + expectedHeadSha = $ExpectedHeadSha + timeoutMinutes = $TimeoutMinutes + newFeedback = @() + } | ConvertTo-Json -Depth 20 +} + +if ($MyInvocation.InvocationName -ne ".") { + Invoke-PrReviewWatcher +} diff --git a/.agents/skills/finish-pr/tests/reply-to-review-thread.tests.ps1 b/.agents/skills/finish-pr/tests/reply-to-review-thread.tests.ps1 new file mode 100644 index 0000000..d45f8a2 --- /dev/null +++ b/.agents/skills/finish-pr/tests/reply-to-review-thread.tests.ps1 @@ -0,0 +1,57 @@ +$ErrorActionPreference = "Stop" + +$global:GhMockCalls = @() +function global:gh { + $global:GhMockCalls += ,@($args) + $global:LASTEXITCODE = 0 + + switch ($global:GhMockCalls.Count) { + 1 { + return '{"data":{"node":{"pullRequest":{"number":25,"repository":{"nameWithOwner":"example-owner/example-repository"}},"comments":{"nodes":[{"databaseId":101}]}}}}' + } + 2 { + return '{"node_id":"reply-1","html_url":"https://example.test/reply-1"}' + } + 3 { + return '{"data":{"node":{"id":"reply-1","url":"https://example.test/reply-1","pullRequestReview":{"id":"review-1","state":"COMMENTED","submittedAt":"2026-01-01T00:00:00Z"}}}}' + } + default { + throw "Unexpected gh call." + } + } +} + +try { + $result = & "$PSScriptRoot\..\scripts\reply-to-review-thread.ps1" ` + -ThreadId "thread-1" ` + -Hostname "github.example" ` + -Body "test reply" | + ConvertFrom-Json -Depth 20 + + if (-not $result.verifiedSubmitted -or $result.review.state -ne "COMMENTED") { + throw "Expected a verified submitted reply." + } + + if ($global:GhMockCalls.Count -ne 3) { + throw "Expected context, reply, and verification calls." + } + + $replyCall = $global:GhMockCalls[1] -join " " + if ($replyCall -notmatch '--hostname github\.example' -or + $replyCall -notmatch '-X POST' -or + $replyCall -notmatch 'repos/example-owner/example-repository/pulls/25/comments/101/replies' -or + $replyCall -notmatch 'body=test reply') { + throw "The helper did not use the atomic single-comment reply endpoint: $replyCall" + } + + $allCalls = $global:GhMockCalls | ForEach-Object { $_ -join " " } + if (($allCalls -join [Environment]::NewLine) -match 'submitPullRequestReview') { + throw "The helper must not submit a shared pending review." + } +} +finally { + Remove-Item Function:\global:gh -ErrorAction SilentlyContinue + Remove-Variable GhMockCalls -Scope Global -ErrorAction SilentlyContinue +} + +Write-Output "All reply-to-review-thread tests passed." diff --git a/.agents/skills/finish-pr/tests/wait-for-pr-review.tests.ps1 b/.agents/skills/finish-pr/tests/wait-for-pr-review.tests.ps1 new file mode 100644 index 0000000..9b77892 --- /dev/null +++ b/.agents/skills/finish-pr/tests/wait-for-pr-review.tests.ps1 @@ -0,0 +1,250 @@ +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot\..\scripts\wait-for-pr-review.ps1" ` + -Wait ` + -StatePath "unused-test-state.json" ` + -ExpectedHeadSha "unused-test-sha" ` + -ReviewRequestedAt ([DateTimeOffset]::MinValue) + +function Assert-Equal { + param($Expected, $Actual, [string]$Message) + + if ($Expected -ne $Actual) { + throw "$Message Expected '$Expected', got '$Actual'." + } +} + +function New-Check { + param( + [string]$Status = "completed", + [string]$Conclusion = "success", + [string]$StartedAt = "2026-08-12T12:00:00Z" + ) + + return [pscustomobject]@{ + id = 123 + status = $Status + conclusion = $Conclusion + startedAt = $StartedAt + completedAt = "2026-08-12T12:01:00Z" + headSha = "new-sha" + } +} + +function New-Dashboard { + param( + [string]$Verdict = "Approved", + [string]$UpdatedAt = "2026-08-12T12:00:30Z", + [string]$Id = "dashboard" + ) + + return [pscustomobject]@{ + id = $Id + kind = "issue_comment" + authorLogin = "gitar-bot" + authorType = "Bot" + body = "
Code Review $Verdict
" + url = "https://example.test/dashboard" + createdAt = "2026-08-12T11:00:00Z" + updatedAt = $UpdatedAt + isGitarDashboard = $true + } +} + +function New-Snapshot { + param( + [string]$HeadSha = "new-sha", + [string]$State = "OPEN", + [array]$Checks = @(), + [AllowNull()]$Dashboard = $null, + [array]$FeedbackItems = @() + ) + + return [pscustomobject]@{ + pullRequest = [pscustomobject]@{ headSha = $HeadSha; state = $State } + feedbackItems = $FeedbackItems + gitarChecks = $Checks + gitarDashboard = $Dashboard + } +} + +$baseline = [pscustomobject]@{ + agentLogin = "example-agent" + baselineHeadSha = "old-sha" + seenFeedbackVersions = @( + [pscustomobject]@{ id = "old-comment"; body = "Old"; updatedAt = "2026-08-12T11:00:00Z" } + ) + gitarDashboard = [pscustomobject]@{ + id = "dashboard" + body = "Code Review Approved" + updatedAt = "2026-08-12T11:00:00Z" + } +} + +Assert-Equal "gitar-bot" (Normalize-Login "Gitar-Bot[BOT]") "Login normalization should strip bot suffixes." +Assert-Equal $true (Test-GitarActor -Login "gitar-bot" -AuthorType "Bot") "The documented Gitar bot must be recognized." +Assert-Equal $false (Test-GitarActor -Login "gitar-maintainer" -AuthorType "User") "A human with a Gitar-like login must not be trusted as the app." + +$pages = '[[{"id":"one"}],[{"id":"two"}]]' | ConvertFrom-Json -Depth 10 +$expanded = @(Expand-PaginatedItems $pages) +Assert-Equal 2 $expanded.Count "Paginated REST results should be flattened." +Assert-Equal "two" $expanded[1].id "Later REST pages should be preserved." + +Assert-Equal "approved" (Get-GitarVerdict (New-Dashboard -Verdict "✅ Approved").body) "Approved should be terminal." +Assert-Equal "approved_with_suggestions" (Get-GitarVerdict (New-Dashboard -Verdict "Approved with Suggestions").body) "Suggestions must remain actionable." +Assert-Equal "changes_requested" (Get-GitarVerdict (New-Dashboard -Verdict "Changes Requested").body) "Changes requested must remain actionable." +Assert-Equal "needs_review" (Get-GitarVerdict (New-Dashboard -Verdict "Needs Review").body) "Needs Review must remain actionable." +Assert-Equal "blocked" (Get-GitarVerdict (New-Dashboard -Verdict "Blocked").body) "Blocked must remain actionable." +Assert-Equal "processing" (Get-GitarVerdict (New-Dashboard -Verdict "Processing").body) "Processing must remain non-terminal." +$approvedWithMisleadingSummary = (New-Dashboard -Verdict "✅ Approved").body + " Changes Requested and blocked are mentioned in summary prose." +Assert-Equal "approved" (Get-GitarVerdict $approvedWithMisleadingSummary) "Summary prose must not override the explicit verdict badge." +$suggestionsWithMisleadingSummary = (New-Dashboard -Verdict "Approved with Suggestions").body + " The summary also says Approved." +Assert-Equal "approved_with_suggestions" (Get-GitarVerdict $suggestionsWithMisleadingSummary) "Only the explicit verdict badge should control classification." +Assert-Equal "unknown" (Get-GitarVerdict "# Code Review`nApproved") "Unstructured prose must not be accepted as a verdict." + +$waiting = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot) -ExpectedSha "new-sha" +Assert-Equal "waiting" $waiting.status "A missing exact-HEAD Gitar check should wait." + +$processing = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -Checks @((New-Check -Status "in_progress" -Conclusion ""))) -ExpectedSha "new-sha" +Assert-Equal "processing" $processing.status "An in-progress Gitar check should wait as processing." + +$failed = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -Checks @((New-Check -Conclusion "failure"))) -ExpectedSha "new-sha" +Assert-Equal "gitar_failed" $failed.status "A failed Gitar check must block completion." + +$approvedDashboard = New-Dashboard -Verdict "✅ Approved" +$approved = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -Checks @((New-Check)) -Dashboard $approvedDashboard) -ExpectedSha "new-sha" +Assert-Equal "approved" $approved.status "A fresh approved dashboard plus successful exact-HEAD check should complete." +Assert-Equal "approved" $approved.verdict "The terminal result should expose Gitar's verdict." + +$suggestionsDashboard = New-Dashboard -Verdict "Approved with Suggestions" +$suggestions = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -Checks @((New-Check)) -Dashboard $suggestionsDashboard) -ExpectedSha "new-sha" +Assert-Equal "feedback" $suggestions.status "Approved with Suggestions must wake the feedback loop." +Assert-Equal "gitar_dashboard" $suggestions.newFeedback[0].kind "Dashboard-only findings should be returned explicitly." + +$staleDashboard = New-Dashboard -Verdict "Approved" -UpdatedAt "2026-08-12T11:00:00Z" +$staleDashboard.body = $baseline.gitarDashboard.body +$stale = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -Checks @((New-Check)) -Dashboard $staleDashboard) -ExpectedSha "new-sha" +Assert-Equal "waiting" $stale.status "An unchanged pre-push dashboard must not approve a new HEAD." + +$humanFeedback = [pscustomobject]@{ + id = "human-comment" + kind = "thread_comment" + authorLogin = "reviewer" + authorType = "User" + body = "Please handle this edge case." + updatedAt = "2026-08-12T12:00:10Z" + isGitarDashboard = $false +} +$feedbackBeforeApproval = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -Checks @((New-Check)) -Dashboard $approvedDashboard -FeedbackItems @($humanFeedback)) -ExpectedSha "new-sha" +Assert-Equal "feedback" $feedbackBeforeApproval.status "New feedback from any source must take precedence over Gitar approval." +Assert-Equal "human-comment" $feedbackBeforeApproval.newFeedback[0].id "The exact new feedback ID should be returned." + +$agentFeedback = [pscustomobject]@{ + id = "agent-comment" + kind = "issue_comment" + authorLogin = "example-agent" + authorType = "User" + body = "I fixed this." + updatedAt = "2026-08-12T12:00:10Z" + isGitarDashboard = $false +} +$agentIgnored = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -Checks @((New-Check)) -Dashboard $approvedDashboard -FeedbackItems @($agentFeedback)) -ExpectedSha "new-sha" +Assert-Equal "approved" $agentIgnored.status "The agent's own replies must not be treated as new review feedback." + +$emptyCopilotReview = [pscustomobject]@{ + id = "copilot-clean" + kind = "review" + authorLogin = "copilot-pull-request-reviewer" + authorType = "Bot" + body = "## Pull request overview`n`nCopilot reviewed 26 out of 26 changed files in this pull request and generated no new comments." + reviewState = "COMMENTED" + updatedAt = "2026-08-12T12:00:10Z" + isGitarDashboard = $false +} +$cleanCopilotIgnored = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -Checks @((New-Check)) -Dashboard $approvedDashboard -FeedbackItems @($emptyCopilotReview)) -ExpectedSha "new-sha" +Assert-Equal "approved" $cleanCopilotIgnored.status "A reviewer's explicit no-findings summary must not be treated as actionable feedback." + +$gitarAutoApproval = [pscustomobject]@{ + id = "gitar-auto-approval" + kind = "review" + authorLogin = "gitar-bot[bot]" + authorType = "Bot" + body = "Gitar has auto-approved this PR ([configure](https://app.gitar.ai))" + reviewState = "APPROVED" + updatedAt = "2026-08-12T12:00:10Z" + isGitarDashboard = $false +} +$gitarAutoApprovalIgnored = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -Checks @((New-Check)) -Dashboard $approvedDashboard -FeedbackItems @($gitarAutoApproval)) -ExpectedSha "new-sha" +Assert-Equal "approved" $gitarAutoApprovalIgnored.status "Gitar's optional Pro auto-approval review must not be treated as feedback." + +$editedFeedback = [pscustomobject]@{ + id = "old-comment" + kind = "issue_comment" + authorLogin = "reviewer" + authorType = "User" + body = "Edited feedback" + updatedAt = "2026-08-12T12:00:10Z" + isGitarDashboard = $false +} +$edited = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -Checks @((New-Check)) -Dashboard $approvedDashboard -FeedbackItems @($editedFeedback)) -ExpectedSha "new-sha" +Assert-Equal "feedback" $edited.status "Edited feedback should wake the audit even when its ID was in the baseline." + +$oldHeadPropagating = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -HeadSha "old-sha") -ExpectedSha "new-sha" +Assert-Equal "waiting" $oldHeadPropagating.status "The pre-push HEAD may remain visible briefly while the push propagates." +$changedHead = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -HeadSha "other-sha") -ExpectedSha "new-sha" +Assert-Equal "head_changed" $changedHead.status "An unexpected HEAD must stop the watcher." +$closed = Resolve-ReviewOutcome -Baseline $baseline -Snapshot (New-Snapshot -State "MERGED") -ExpectedSha "new-sha" +Assert-Equal "pr_closed" $closed.status "A closed PR must stop the watcher." + +$unchangedHeadBaseline = [pscustomobject]@{ + agentLogin = "example-agent" + baselineHeadSha = "new-sha" + seenFeedbackVersions = @() + gitarDashboard = $null +} +$existingCurrentApproval = Resolve-ReviewOutcome -Baseline $unchangedHeadBaseline -Snapshot (New-Snapshot -Checks @((New-Check)) -Dashboard $approvedDashboard) -ExpectedSha "new-sha" +Assert-Equal "approved" $existingCurrentApproval.status "A successful current-HEAD Gitar check should support the no-push path." + +$olderCheck = New-Check -StartedAt "2026-08-12T10:00:00Z" +$olderCheck.id = 1 +$newerCheck = New-Check -Status "in_progress" -Conclusion "" -StartedAt "2026-08-12T12:00:00Z" +$newerCheck.id = 2 +$latestCheck = @(Get-LatestGitarCheck -Snapshot (New-Snapshot -Checks @($olderCheck, $newerCheck)))[0] +Assert-Equal 2 $latestCheck.id "The latest exact-HEAD Gitar run should control convergence." + +$routing = Resolve-RepositoryRouting -Repository "ghe.example/owner/repository" +Assert-Equal "ghe.example" $routing.hostname "Host-qualified repositories should preserve their host." +Assert-Equal "owner/repository" $routing.apiRepository "API routing should strip the host." + +$baselinePath = Join-Path ([IO.Path]::GetTempPath()) "finish-pr-gitar-baseline-test.json" +function Invoke-GhJson { + param([string[]]$GhArgs) + + if ($GhArgs[0] -eq "api" -and $GhArgs[-1] -eq "user") { + return [pscustomobject]@{ login = "example-agent" } + } + throw "Unexpected gh call: $($GhArgs -join ' ')" +} +function Get-PrReviewSnapshot { + return New-Snapshot -Dashboard (New-Dashboard -Verdict "Approved") -FeedbackItems @($humanFeedback) +} + +try { + $CaptureBaseline = $true + $StatePath = $baselinePath + $PrNumber = 25 + $Repository = "owner/repository" + $Hostname = "ghe.example" + Invoke-PrReviewWatcher | Out-Null + + $captured = Get-Content -Raw -LiteralPath $baselinePath | ConvertFrom-Json -Depth 100 + Assert-Equal "ghe.example/owner/repository" $captured.repository "Baseline capture should retain repository routing." + Assert-Equal "example-agent" $captured.agentLogin "Baseline capture should record the authenticated agent." + Assert-Equal "human-comment" $captured.seenFeedbackVersions[0].id "Baseline capture should version feedback IDs." + Assert-Equal "dashboard" $captured.gitarDashboard.id "Baseline capture should retain the dashboard version." +} +finally { + Remove-Item -LiteralPath $baselinePath -ErrorAction SilentlyContinue +} + +Write-Output "All wait-for-pr-review tests passed." diff --git a/.agents/skills/implement-task-linear/SKILL.md b/.agents/skills/implement-task-linear/SKILL.md new file mode 100644 index 0000000..5ec4f11 --- /dev/null +++ b/.agents/skills/implement-task-linear/SKILL.md @@ -0,0 +1,128 @@ +--- +name: implement-task-linear +description: Implement one Linear issue end to end in the current repository, using its optional originating spec, plan, domain context, and ADRs when available. +disable-model-invocation: true +--- + +# Implement Task from Linear + +Implement exactly one Linear issue. The issue is the required input and scope boundary; its provenance chain and repository context refine how to implement it without silently adding work. + +## 1. Resolve the target and Linear boundary + +Require an unambiguous Linear issue identifier or URL. Ask for one when absent. + +Fetch the target's full description, status, labels, team, project, relation identifiers, comments, and requirement-bearing attachments. Record its exact team ID and project ID. If it has no project, ask the user to assign it or authorize assignment to a named project before inspecting surrounding Linear work. + +After resolving the target, apply this boundary to every other Linear issue read or mutation: + +- query with both recorded IDs, then verify both IDs on every result; +- discover parents, sub-issues, blockers, duplicates, and related issues through scoped queries rather than directly fetching an unverified identifier; +- treat a cross-scope relation as unavailable context and report only its existence; +- create issues only with both recorded IDs. + +Resolve the team's relevant workflow states, especially In Progress, In Review, Backlog, and any blocked state, by ID. Report ambiguity instead of guessing. + +This step is complete when the target, acceptance criteria, team/project IDs, and usable workflow states are known. + +## 2. Follow the provenance chain + +Determine the repository root and read every applicable `AGENTS.md` before other repository work. + +Then resolve context in this order. Each source is optional unless the target explicitly depends on it: + +1. **Originating spec** — follow every explicit `Source` path or URL, spec link, design link, or requirement-bearing attachment in the target. When `to-linear` represented the spec as a native parent, find that parent through a team-and-project-scoped query, verify both IDs, then read its full description and comments. If no provenance is recorded, search `docs/`, `specs/`, and `.scratch/` for a single clear match to the target title, source name, or feature slug; ask before choosing among plausible matches. Inspect in-scope parents, blockers, milestone or cycle neighbours, and overlapping issues when they can clarify intent, dependencies, or drift. +2. **Plan** — read root `PLAN.md` fully when it exists. Use it for the larger outcome, sequencing, dependencies, and the target's place in the plan. +3. **Domain context** — read root `CONTEXT.md` fully when it exists. Also read root `CONTEXT-MAP.md` fully when it exists and follow every context entry applicable to the target. Context files define project language and domain boundaries, not implementation scope. +4. **Decisions** — read applicable ADRs under root `docs/adr/` and any context-specific `docs/adr/`. Follow pertinent artifact pointers from the selected spec, plan, context files, ADRs, and current conversation. +5. **Repository truth** — inspect the implicated code, tests, documentation, schemas, migrations, generated artifacts, configuration, and git status. Preserve unrelated user changes. + +The prerequisite skills may leave different parts of this chain: + +- `grill-with-docs` may leave `CONTEXT.md`, `CONTEXT-MAP.md`, and ADRs; +- `to-spec` publishes implementation decisions, agreed testing seams, and out-of-scope boundaries in an originating tracker issue; +- `to-linear` carries that origin into the target through a native parent or `Source` reference and may copy durable constraints into `Context`. + +Missing optional artifacts do not block implementation. Explicit pointers do require resolution: read them, or report why they are unavailable. + +Use this evidence hierarchy: + +- the target issue defines the deliverable and acceptance criteria; +- the originating spec and `PLAN.md` explain intent, sequencing, and exclusions without expanding the target; +- context files and ADRs constrain vocabulary, boundaries, and durable decisions; +- the repository defines current implementation truth. + +Surface material conflicts or stale evidence. Ask only when a conflict changes the intended behaviour, public interface, compatibility, or scope. + +This step is complete when every explicit provenance pointer is resolved or reported unavailable, every applicable context artifact is read, and every target requirement is classified as implemented, partial, missing, contradicted, or blocked. + +## 3. Plan the delivery + +Before editing, give the user: + +- the target outcome and assumptions; +- the context sources consulted, including missing or unavailable expected artifacts; +- material drift between Linear, the originating spec, `PLAN.md`, domain documents, and the repository; +- a short vertical-slice plan with a verification check for each step; +- the public testing seams already agreed in the spec, or the highest existing seams that fit the change. + +Map every acceptance criterion and durable constraint to an implementation change or concrete verification. Existing behaviour counts only after verification. If choosing a seam would create or materially change a public interface, confirm it with the user when the upstream artifacts did not already settle it. + +Move the target to the resolved In Progress state when implementation starts, unless it is already further along or the user asked to preserve its status. + +This step is complete when every acceptance criterion has a delivery and verification path and every material design choice is settled. + +## 4. Implement in verified slices + +Implement the minimum coherent change that satisfies the whole target: + +- follow repository conventions and preserve unrelated work; +- for testable behaviour, work red then green at the agreed public seams, one vertical slice at a time; +- run the smallest relevant test and typecheck after each slice, then the broader relevant suite once near the end; +- update required documentation, schemas, migrations, generated artifacts, and configuration; +- keep required target work in the current change rather than deferring it to manufacture completion; +- leave commits, pushes, pull requests, deployment, and release to an explicit user request. + +When a test cannot reasonably be written or a full suite cannot run, use the strongest available verification and record the limitation. + +Continue until every acceptance criterion passes or a genuine blocker remains. + +## 5. Review and reconcile + +Inspect the complete diff before changing final Linear state: + +- **Standards** — check every changed area against applicable repository instructions, conventions, and ADRs. +- **Spec** — check every target criterion and originating-spec constraint for missing work, incorrect behaviour, and scope creep. + +Fix actionable findings and rerun affected checks. Re-fetch the target and every mutated related issue, then reconfirm the team/project boundary. + +When every required outcome is implemented and verified: + +1. Add a concise target comment mapping each acceptance criterion to implementation and verification evidence. +2. Move the target to In Review only when its current state is earlier. + +Preserve completed, cancelled, archived, In Review, and other terminal or further-along states. When blocked, leave the target In Progress or use the resolved blocked state and record the unmet criterion, evidence, and dependency. + +## Related Linear work + +The user authorizes minimal related-issue mutations inside the recorded team/project boundary when implementation evidence justifies them. + +Create a follow-up only when it is concrete, independently actionable, genuinely outside the target, and absent after a scoped overlap search. Create it in the same team/project, normally in Backlog, with observable acceptance criteria and a native relation to the target when supported. A prerequisite that blocks completion must be related and reported as a blocker rather than used to declare the target complete. + +Update an existing related issue only when repository evidence makes its description, relations, or status stale. Before treating it as satisfied, read every acceptance criterion and verify complete coverage. Move it to In Review only from an earlier state; preserve terminal and further-along states. Partial or title-only overlap does not justify a transition. + +## Handoff + +Report: + +- target identifier, URL, and final status; +- implemented outcome and changed surfaces; +- verification commands and results; +- acceptance-criteria coverage; +- context sources used and unavailable explicit pointers; +- material drift or conflicts found; +- every related issue read that changed an implementation decision; +- every issue created or modified, with reason, status, and URL; +- blockers, limitations, and follow-up work. + +Say explicitly when no related Linear issues changed. diff --git a/.agents/skills/implement-task-linear/agents/openai.yaml b/.agents/skills/implement-task-linear/agents/openai.yaml new file mode 100644 index 0000000..70e3189 --- /dev/null +++ b/.agents/skills/implement-task-linear/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Implement Linear Task" + short_description: "Implement a Linear issue with full context" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/test-changes/SKILL.md b/.agents/skills/test-changes/SKILL.md new file mode 100644 index 0000000..44b21ae --- /dev/null +++ b/.agents/skills/test-changes/SKILL.md @@ -0,0 +1,79 @@ +--- +name: test-changes +description: Explain how to manually inspect and test the work just completed in this conversation, using PowerShell commands where commands are needed. +disable-model-invocation: true +--- + +# Test Changes + +Produce a practical handoff for manually verifying the work completed in the current conversation. + +## 1. Establish the test surface + +Reconstruct the delivered outcome from the conversation, including later corrections and failed or partial attempts. Treat the current workspace, connected systems, and running environment as confirmation of the final state, not as a substitute for the conversation. + +Inspect read-only evidence where useful: changed files, `git diff`, repository status, available scripts, project documentation, generated artifacts, external records, and already-running services. Account for every user-visible behaviour, artifact, or external state changed by the completed work. Separate unrelated pre-existing changes. + +If no completed or inspectable change exists, say so and identify what must be completed before manual testing is meaningful. + +Completion criterion: every change made in this conversation is mapped to something the user can inspect or exercise, or explicitly marked as having no manual test surface. + +## 2. Build the shortest valid test path + +Start with what the user can see **right now**, before asking them to run anything. Include existing files, rendered artifacts, open applications, running services, URLs, or visible state only when confirmed. + +Then provide the minimum prerequisites and commands needed to reach the test surface. Use PowerShell syntax for terminal commands: + +- Begin from the correct directory with `Set-Location`. +- Give commands in execution order and make each block directly copyable. +- Use the repository's real scripts, project names, ports, and paths. +- Include dependency installation, build, migration, seed, server, or authentication steps only when required. +- State whether a long-running command occupies the terminal and when a second PowerShell window is needed. +- Prefer the smallest command that exercises the changed area; add broader checks only when they expose materially different risk. +- Mark any value the user must supply, such as ``, and say where it comes from. + +Link directly to local artifacts and external pages when they can be opened from the response. When no terminal step is relevant, give direct inspection actions without manufacturing a PowerShell command. + +Do not mutate application data merely to prepare the explanation. When the test itself changes data, warn the user and give a cleanup or reset step when one is available. + +Completion criterion: following the commands from a clean PowerShell window reaches every testable changed surface without hidden steps. + +## 3. Specify observations + +For each test, state: + +1. The exact action to perform. +2. The exact result to expect. +3. The detail that proves the new or changed behaviour, including relevant text, controls, values, files, logs, status codes, or before/after differences. +4. A meaningful failure signal. + +Cover the happy path first, then changed edge cases and regressions justified by the work. Describe observable behaviour rather than implementation details. Do not invent certainty: label inferred expectations and state any limitation that prevents a complete manual test. + +Completion criterion: the user can decide pass or fail for every test without interpreting vague phrases such as “works correctly.” + +## Response format + +Omit empty sections. + +```md +## What you can see now +- + +## Start or prepare + + +## Manual tests +### 1. +**Action:** +**Expected:** +**Changed:** +**Failure:** + +## Cleanup + + +## Limitations +- +``` + +Keep the handoff concise, but include every changed test surface. Do not repeat automated test results unless they help the user interpret a manual test. diff --git a/.agents/skills/test-changes/agents/openai.yaml b/.agents/skills/test-changes/agents/openai.yaml new file mode 100644 index 0000000..b687a25 --- /dev/null +++ b/.agents/skills/test-changes/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Test Changes" + short_description: "Manually verify the work just completed" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/to-linear/SKILL.md b/.agents/skills/to-linear/SKILL.md new file mode 100644 index 0000000..8464321 --- /dev/null +++ b/.agents/skills/to-linear/SKILL.md @@ -0,0 +1,115 @@ +--- +name: to-linear +description: Break a plan, spec, referenced issue, or current conversation into approved Linear milestones and tracer-bullet tickets in a confirmed project, with native dependencies, Backlog status, and focused labels. +disable-model-invocation: true +--- + +# To Linear + +Create narrow, end-to-end Linear tickets. Every ticket belongs to the confirmed project and a native milestone. Mark only agent-suitable work with `Agent`. + +## Process + +### 1. Confirm the destination + +Before analysing or drafting tickets: + +1. List all available teams with the Linear MCP server and ask the user to choose one. +2. List that team's projects and ask the user to choose one. +3. Repeat both names and obtain explicit confirmation. + +Never infer the destination. Every ticket in the run uses the confirmed team and project unless the user restarts selection. + +### 2. Resolve the source, context, and existing tickets + +Resolve exactly one **primary source** for scope, in this order: + +1. An explicit source supplied with the invocation or explicitly designated by the user for this run: a file, issue identifier or URL, or pasted plan/spec. Fetch and read the full body and comments of a referenced issue. +2. Otherwise, the most recent settled plan or spec in the current conversation. If it points to an artifact, fetch and read that artifact fully. +3. Otherwise, root `PLAN.md`, when present; read it fully. +4. Otherwise, stop and ask the user for the source. + +An explicit source wins even when `PLAN.md` exists. State which source was selected. Do not merge multiple plausible sources into new scope; ask when source selection is ambiguous or when a contradiction materially changes the intended outcome. + +Treat `CONTEXT.md`, applicable `AGENTS.md`, referenced sources, relevant ADRs, designs, code, and tests as supporting context. Read them when they affect ticket accuracy. `CONTEXT.md` is authoritative for domain language, boundaries, and durable decisions, but supporting context never silently expands the primary source. + +Preserve provenance when the primary source has a stable reference. If it is a Linear issue in the confirmed team and project, propose a native parent relationship. Otherwise include the reference in each new ticket's optional `Source` section. Never modify or close the source issue unless the user explicitly approves it. + +For broad work, use subagents for bounded, independent, read-only exploration such as separate module investigations, prior-art searches, or overlap checks. Continue other independent analysis while they work, then reconcile every result before drafting. The primary agent owns source selection, the complete proposal, user approval, and every Linear mutation; never create or edit Linear items concurrently through subagents. + +After destination confirmation and before drafting, list the project's milestones and every issue whose team and project both match the confirmed destination, following pagination and including completed or archived issues where available. Search same-team issues in other projects only to surface possible cross-project overlap; never reuse them in this run. Compare in-project milestone scope and issue titles, descriptions, acceptance criteria, and status with the primary source: + +- Reuse existing milestones that match a planned phase. +- Reuse existing issues that already cover planned work only when their team and project both match the confirmed destination; do not duplicate them. +- Narrow or omit proposed work that partially overlaps, and explain the overlap during review. +- Do not modify, rename, or close existing issues unless the user explicitly approves it. + +Recheck for newly created overlaps immediately before publishing. + +### 3. Draft milestones and tracer-bullet tickets + +Each ticket must deliver a narrow, complete, independently verifiable outcome. Prefer vertical slices across necessary layers over layer-by-layer tasks. Size agent work for one fresh context window and human work as one focused action. Include human judgement, credentials, physical action, or approval as **Human** tickets; publish them normally without `Agent`. + +Before slicing, look for prefactoring that makes the requested change easier. Create prefactoring tickets first only when they have concrete, independently verifiable outcomes needed by later slices; never invent unrelated cleanup. + +Assign every ticket to exactly one native Linear project milestone. Reuse an existing milestone that matches the outcome; otherwise create one, including for a single-ticket change. Milestone names describe outcomes or phases and have no numeric prefix. Order milestones chronologically and topologically: every prerequisite milestone must appear earlier. + +Within each milestone, name every issue: + +```text +III - Title +``` + +`III` is zero-padded to three digits. Start each milestone at `000`, increment in topological display order (`001`, `002`), and reset to `000` for the next milestone. Avoid numbers already used in that milestone. + +Topologically order all tickets without inventing dependencies. Every blocker must be earlier in the total milestone/task order: + +- A ticket may depend on a lower issue number in its milestone. +- A ticket may depend on any ticket in an earlier milestone. +- A ticket must never depend on a later issue in its milestone or any future milestone. + +A lower issue number does not itself create a dependency; only a native `blockedBy` relationship does. Keep independent tickets unblocked. They form the execution **frontier** and may be assigned to separate agents concurrently. + +If an existing milestone or issue conflicts with this order, surface it and ask before renaming, moving, or renumbering it. For wide mechanical refactors that cannot land green as vertical slices, use ordered expand–migrate–contract tickets. + +### 4. Review with the user + +Present the selected primary source and the complete proposal in milestone order. For each milestone show existing/new status; for each ticket show title, existing/new status, blockers, delivered outcome, acceptance criteria, executor (`Agent` or `Human`), labels, and source provenance. Explicitly show omitted or narrowed overlaps and the initial frontier of independent agent tickets. + +Ask the user to approve milestone names/order, ticket order, granularity, dependencies, overlap decisions, criteria, labels, provenance, and parallel frontier. Do not publish until explicitly approved. + +### 5. Prepare Linear metadata + +Use the Linear MCP server to: + +1. Resolve the confirmed team's unambiguous `Backlog` state. +2. List existing labels; reuse durable type/domain labels. +3. Create missing durable labels only when useful. +4. Ensure `Agent` exists if any ticket is agent-suitable. + +Use two or three labels where useful. Agent tickets normally use `Agent`, one type, and optionally one domain label. Human tickets use type/domain labels but never `Agent`; do not invent `Human` unless asked. Avoid status, project, team, redundant, or one-off labels. + +### 6. Publish and verify + +After the final overlap recheck, create missing milestones in approved chronological order, then create issues milestone-by-milestone and ascending by title number. Assign each newly created issue to its milestone and the confirmed `team`, `project`, resolved Backlog `state`, and approved `labels`. Preserve every reused issue's existing state, milestone, project, labels, and relations unless the reviewed proposal explicitly listed each intended mutation and the user approved it. Add approved native parent and `blockedBy` relationships using existing or already-created identifiers. Never use prose instead of available native relations. + +Use Linear's native milestone/issue reordering capability when available. Otherwise creation order plus numeric titles is the source of truth; verify the returned order and clearly report any manual Linear reorder still required. Never invent milestone target dates merely to force ordering. + +Do not assign, delegate, or add issues to a cycle unless asked. For newly created issues, verify milestone assignment, title, team, project, Backlog state, labels, and blockers. For reused issues, verify identity and approved mutations without normalizing unapproved metadata. Confirm new agent tickets have `Agent`, new human tickets do not, and every newly created or explicitly approved blocker is earlier. Correct in-scope mismatches before reporting identifiers and URLs. + +End with plain text `Milestone order: ` followed by issue identifiers/URLs in that same milestone and ascending-number order. Then write `Parallel frontier: ` listing unblocked `Agent` tickets that separate agents can implement concurrently, or `Parallel frontier: none`. + +## Issue description + +```md +## What to build + +## Acceptance criteria +- [ ] +## Context + +## Source + +``` + +Avoid brittle paths and snippets. Example: milestone `Quote flow` contains `000 - Create quote form`, then `001 - Validate requests`; milestone `Launch` starts again at `000 - Approve production wording`. diff --git a/.agents/skills/to-linear/agents/openai.yaml b/.agents/skills/to-linear/agents/openai.yaml new file mode 100644 index 0000000..ea3fb4b --- /dev/null +++ b/.agents/skills/to-linear/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "To Linear" + short_description: "Publish ordered milestones and tickets to Linear" +policy: + allow_implicit_invocation: false diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..ce7edb5 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "skills": { + "finish-pr": { + "source": "pinguapps/skills", + "sourceType": "github", + "skillPath": "skills/finish-pr/SKILL.md", + "computedHash": "1d345aa4432d4f2994a428e958ef11a5fb25ac0f847897acfbf077c1bc53f4c7" + }, + "implement-task-linear": { + "source": "pinguapps/skills", + "sourceType": "github", + "skillPath": "skills/implement-task-linear/SKILL.md", + "computedHash": "5da7d95f96b28f961b2a0ce4484e6d1f0ae9ca0230a66bf1d5aa193de8abf3af" + }, + "test-changes": { + "source": "pinguapps/skills", + "sourceType": "github", + "skillPath": "skills/test-changes/SKILL.md", + "computedHash": "ae2b10b64b37c72f447e8fb35f8275a842093fe21c891ee1702fefccb63e79af" + }, + "to-linear": { + "source": "pinguapps/skills", + "sourceType": "github", + "skillPath": "skills/to-linear/SKILL.md", + "computedHash": "0a849baa9fa3e2d65dbd204ee6a722a49f8e4a95c75d6dc6916ae50c2f746e69" + } + } +} From d6971dadd48751199b0e1a4127a1b1d4258823d3 Mon Sep 17 00:00:00 2001 From: Matthew Parker Date: Sat, 22 Aug 2026 21:44:19 +0100 Subject: [PATCH 3/3] fix(ci): retain VSTest for Reqnroll --- Directory.Build.targets | 2 +- Directory.Packages.props | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Build.targets b/Directory.Build.targets index 74c3cf1..1893924 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -9,7 +9,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Directory.Packages.props b/Directory.Packages.props index 40a7225..13577fa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ - + - \ No newline at end of file +