From 39cf293fdd13ad9ff1fa44310f431641973fc136 Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Thu, 23 Jul 2026 21:52:02 +1000 Subject: [PATCH 1/3] feat(powershell,nvim,claude): terminal-editor seam for local ADO PR reviews prr (Invoke-AdoPrReview, profile Phase 2b): fzf-discovers an active PR via az repos pr list (or takes -Id), guards the bare-worktree layout, creates or reuses the detached review worktree, refuses if dirty, and hands off to nvim +AdoPrReview. Load ado-pr.nvim from its local checkout via guarded rtp prepend (not vim.pack - no remote yet, keeps machine paths out of the lockfile); ga opens its :AdoPr picker beside the diffview maps. review-ado-pr skill gains a discovery step so 'review my pending PRs' works without a pasted id. Pester coverage for the prr guards, worktree creation, and nvim handoff via an AST-extracted function and a PATH-shimmed nvim. --- CLAUDE.md | 5 +- claude/skills/review-ado-pr/SKILL.md | 18 ++-- nvim/lua/config/diffview.lua | 7 ++ nvim/lua/config/plugins.lua | 9 ++ powershell/Microsoft.PowerShell_profile.ps1 | 48 +++++++++++ tests/ado-pr-review.Tests.ps1 | 95 +++++++++++++++++++++ 6 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 tests/ado-pr-review.Tests.ps1 diff --git a/CLAUDE.md b/CLAUDE.md index 4247eb6..26ff3fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ The PowerShell profile (`powershell/Microsoft.PowerShell_profile.ps1`) is a sing 1. **Phase 1 (blocking, must stay cheap)**: module-availability cache (single `PSModulePath` scan into `$global:ProfileModules`), PSReadLine with `PredictionSource History` only, keybindings, dot-source `Profile/Set-Prompt.ps1` (which defines but no longer *calls* `Initialize-AzTimer`), aliases, tool wrapper functions (`y` for yazi). 2. **Phase 2a (first idle)**: `Initialize-DeferredProfile` loads PSFzf and zoxide, upgrades PSReadLine to `HistoryAndPlugin`, sets the fd-backed `FZF_*_COMMAND` vars, defines the eza `ll`/`la`/`lt` helpers, and calls `Initialize-AzTimer` (Az context timer — `runspace.Open()` + first eventing call ~200ms, moved off the load path). Guarded by `$global:ProfileDeferredDone`. These are the interactive tools needed immediately after the prompt appears. -3. **Phase 2b (next idle)**: `Initialize-DeferredProfileSecondary` registers the az + zellij native tab-completers and loads git-completion (+ `g` alias completer) and WinGet CommandNotFound. Guarded by `$global:ProfileDeferredSecondaryDone`. Split from 2a so the first keypress isn't blocked by their import cost. +3. **Phase 2b (next idle)**: `Initialize-DeferredProfileSecondary` registers the az + zellij native tab-completers, defines the `prr`/`Invoke-AdoPrReview` ADO PR review helper (az-gated: fzf PR picker → detached `review` worktree → `nvim "+AdoPrReview "`, pairing with ado-pr.nvim), and loads git-completion (+ `g` alias completer) and WinGet CommandNotFound. Guarded by `$global:ProfileDeferredSecondaryDone`. Split from 2a so the first keypress isn't blocked by their import cost. 4. **Phase 3 (async runspace)**: Az context refresh on a 60s timer. The timer is created in Phase 2a (`Initialize-AzTimer`); the refresh itself runs in a background runspace wired up in `Set-Prompt.ps1`. When adding to the profile, classify by cost first — anything that loads .NET assemblies or scans the filesystem belongs in Phase 2, not Phase 1. Use the `$global:ProfileModules` cache rather than calling `Get-Module -ListAvailable` again. @@ -137,7 +137,7 @@ User-scope Claude Code subagents live as flat `.md` files (frontmatter + system- Modular Lua config targeting Neovim 0.12+. Uses `vim.pack` (Neovim 0.12 built-in package manager) — no external plugin manager. LSP is configured via the native `vim.lsp.config` / `vim.lsp.enable` API (not the lspconfig Lua framework). -Load order: `performance` → `user` → `plugins` → `options` → `keymaps` → `autocmds` → `treesitter` → `orgmode` → `lsp` → `dap` → `gitsigns` → `ui` +Load order: `performance` → `user` → `plugins` → `options` → `keymaps` → `autocmds` → `treesitter` → `orgmode` → `lsp` → `dap` → `gitsigns` → `diffview` → `ui` The `vim/` directory (Vimscript setup) is the older Vim config — kept as the Linux fallback, not the active Neovim config. `vim/vimrc` is the vimrc; Vim finds it automatically at `~/.vim/vimrc` (Linux) or `~/vimfiles/vimrc` (Windows). @@ -155,6 +155,7 @@ The `vim/` directory (Vimscript setup) is the older Vim config — kept as the L - **C# / .NET**: `roslyn.nvim` (seblyng) is the C# LSP — not OmniSharp. Configured in `lsp.lua` (`require('roslyn').setup({})` + `vim.lsp.config('roslyn', …)`), gated on `dotnet` being on PATH (no `user_config` field). Analysis scope is pinned to `openFiles` (not `fullSolution`) — a deliberate perf choice. `*.cs` is a built-in filetype, so no autocmd is needed. Testing/run/build are buffer-local keymaps (`nt/nr/nb`) in `after/ftplugin/cs.lua` that shell out to the `dotnet` CLI — no `easy-dotnet`/plugin, kept minimal on purpose. Inlay hints are off by default (`th` toggles). Prerequisite global tool: `dotnet tool install -g roslyn-language-server --prerelease` (auto-detected on PATH). - **PowerShell debugging**: `nvim-dap` (client only) + PowerShell Editor Services as the debug server — PSES *is* the DAP server VS Code drives, so no separate debugger. Wired in `lua/config/dap.lua` (loaded after `lsp`), gated on the same `pwsh_bundle_path` as the LSP; buffer-local keymaps in `after/ftplugin/ps1.lua` (``/``/``/``/``, `b`/`B`, `nr`, `e`). State inspection uses nvim-dap's **built-in** `dap.ui.widgets.hover()` (`e`) — deliberately no `nvim-dap-ui`/`nvim-nio`/virtual-text; the built-in float + REPL are the effective minimum. The `ps1` adapter starts its **own** PSES process in `-DebugServiceOnly -EnableConsoleRepl` inside a `jobstart({ term = true })` terminal (the PowerShell Extension Terminal the debuggee runs in), then connects nvim-dap over the `debugServicePipeName` named pipe from PSES's session-details JSON (`type = 'pipe'`). **Deliberately independent of the `powershell_es` LSP** — separate PSES process, so the hand-tuned LSP block in `lsp.lua` is untouched; the two never share the session file (`powershell_es.dap_session.json` vs the LSP's stdio transport). This is a from-scratch port of the DAP handshake in `TheLeoP/powershell.nvim` (the `event_powerShell/sendKeyPress` listener and 60×500ms session-file poll included) **without** taking the plugin as a dependency — keep it in sync if that upstream changes. Do not re-fold this into the LSP config or add the plugin. - **oil git status**: `refractalize/oil-git-status.nvim` shows per-file git status in oil's two sign columns (left = index, right = working tree). Requires oil's `win_options = { signcolumn = 'yes:2' }` (set in `ui.lua`) and `require('oil-git-status').setup()`. +- **ado-pr.nvim is loaded via rtp prepend, not vim.pack** (`plugins.lua`): it is a local checkout (`E:/Personal Projects/ado-pr.nvim`) with no git remote yet, and a vim.pack local-path spec would bake a machine-absolute `src` into `nvim-pack-lock.json`. The prepend is guarded on the directory existing, so other machines stay inert. `ga` (`diffview.lua`) opens its `:AdoPr` picker; the shell entry point is the profile's `prr`. Switch to a vim.pack spec once the plugin gets a remote. - **Lockfile discipline**: `nvim/nvim-pack-lock.json` is vim.pack-managed — any `plugins.lua` change must be reflected there (launch nvim or `vim.pack.update()` to regenerate) and **committed together** with the plugin edit. - **Offline / GitHub-blocked install**: `nvim/Install-PluginsOffline.ps1` provisions plugins and Treesitter parsers without cloning from `github.com`, for machines where it is blocked but `codeload.github.com` (the ZIP host) is reachable. **⚠ Its parser step is `master`-specific and is currently BROKEN for the `main` migration** — it drives nvim-treesitter's old local-path compile (no `tree-sitter` CLI, no curl/tar) and stages parser sources master's way; `main` compiles via `tree-sitter build`. The plugin-staging step still works; the parser + org-grammar steps need reworking before this script is usable on `main`. The rest of this bullet describes the still-master design: It reads `nvim-pack-lock.json`, downloads each plugin as a ZIP of its pinned rev into `/site/pack/core/opt/` — `vim.pack` loads any dir matching a valid lockfile entry via `:packadd` with no git calls, so a plain ZIP extract works. For parsers, it stages each `ensure_installed` grammar's source from codeload (repo from nvim-treesitter's parser config, rev from its `lockfile.json`) and lets nvim-treesitter compile from the **local** path (no network), using `zig` as the compiler. Every `nvim` call runs without the user config (`-u NONE` / minimal `packadd`) so `vim.pack.add` never fires a blocked clone. Rev-aware idempotent (per-dir `.codeload-rev` markers + a central parser-rev file) — re-run after a lockfile change to update. The parser step needs a C compiler (`winget install zig.zig`) **and** the `git` executable present (nvim-treesitter requires it, though no network is used). A third step stages **orgmode's own `tree-sitter-org` grammar**, which the parser step does *not* cover: nvim-orgmode bypasses nvim-treesitter entirely and, on first `setup()`, clones the grammar from `github.com` into `stdpath('cache')` and compiles it into its own plugin dir. The script stages that source from codeload at the version pinned in orgmode's `lua/orgmode/utils/treesitter/install.lua` (`required_version`, read not hardcoded) and lets orgmode compile it from the local path by overriding its `get_path` — orgmode *has* a local-path branch but hardcodes the github URL inside `run()`, so it's unreachable from config. This keeps orgmode's own compiler args, install path and lock format as the single source of truth. It must run **after** the plugin step: `parser/org.so` and `.org-ts-lock.json` both live *inside* the plugin dir, so a re-extract wipes them — which conveniently makes orgmode's own lock file a self-correcting marker (hence no `.codeload-rev` for it). Needs only the compiler, not `git` (orgmode shells out to git purely to clone, which is what codeload staging replaces). The script recreates `/parser/` before invoking orgmode: orgmode ships that dir but never creates it, so deleting `org.so` to force a rebuild would otherwise fail with an opaque "cannot find the path specified" from its move. - **No per-machine variants yet**: single `init.lua` only — no `init.WORK-PC.lua`. Apply the `.` suffix convention when machine-specific behaviour is needed. diff --git a/claude/skills/review-ado-pr/SKILL.md b/claude/skills/review-ado-pr/SKILL.md index b248c9e..f3c1a39 100644 --- a/claude/skills/review-ado-pr/SKILL.md +++ b/claude/skills/review-ado-pr/SKILL.md @@ -1,6 +1,6 @@ --- name: review-ado-pr -description: "Review an Azure DevOps pull request locally, end-to-end. Use when the user says 'review this ADO PR', 'review PR ', 'review azure devops PR ', or asks to look over an ADO pull request. Fetches the PR into the dedicated review worktree, runs IaC gates (Bicep), reads the diff, does a cross-model review, and — only on approval — posts inline threads / a vote back. Read-only by default. NOT for GitHub PRs (use the review skill) or the local working diff (use /code-review)." +description: "Review an Azure DevOps pull request locally, end-to-end. Use when the user says 'review this ADO PR', 'review PR ', 'review azure devops PR ', 'review my pending ADO PRs' (no id needed — it discovers), or asks to look over an ADO pull request. Fetches the PR into the dedicated review worktree, runs IaC gates (Bicep), reads the diff, does a cross-model review, and — only on approval — posts inline threads / a vote back. Read-only by default. NOT for GitHub PRs (use the review skill) or the local working diff (use /code-review)." metadata: author: justin version: "1.0.0" @@ -15,7 +15,8 @@ read it, review it across models, and report. Posting anything back to the PR (t ## When to use - The user gives an ADO PR URL or id and wants it reviewed. -- Trigger phrases: "review this ADO PR", "review PR ", "review azure devops PR ". +- Trigger phrases: "review this ADO PR", "review PR ", "review azure devops PR ", + "review my pending ADO PRs" (no id — discover via step 1). - NOT GitHub (use the `review` skill), NOT the uncommitted working diff (use `/code-review`). ## Preconditions @@ -27,7 +28,12 @@ read it, review it across models, and report. Posting anything back to the PR (t ## Steps -1. **Resolve the PR.** `az repos pr show --id --organization --query "{status:status, sourceRefName:sourceRefName, targetRefName:targetRefName, title:title, mergeStatus:mergeStatus}"`. +1. **Resolve the PR.** No id/URL given ("review my pending PRs")? Discover first — run inside the + repo (org/project/repo auto-detect from the remote): + `az repos pr list --status active --query "[].{id:pullRequestId, title:title, author:createdBy.displayName}" -o table` + and let the user pick. (From a shell, the `prr` profile helper is the same discovery: fzf-pick → + review worktree → nvim.) Then: + `az repos pr show --id --organization --query "{status:status, sourceRefName:sourceRefName, targetRefName:targetRefName, title:title, mergeStatus:mergeStatus}"`. - Confirm `status` is `active`. A `lastMergeCommit` in the full payload is a **merge preview**, not proof it merged — trust `status`, not the presence of a merge commit. - Note the target branch; the review diff is always three-dot against it. @@ -91,5 +97,7 @@ read it, review it across models, and report. Posting anything back to the PR (t ## Related - `bicep-tdd` — the IaC gate for step 4. · `deep-review` / `codex-review` — the review passes in step 5. -- The `local-pr-review` brain initiative and `ado-pr.nvim` (a Neovim ADO PR plugin being built to fold - steps 2–3 and 7 into the editor). +- `prr` (PowerShell profile helper) — collapses discovery + steps 2–3 from a shell: fzf PR picker → + review worktree → `nvim "+AdoPrReview "`. +- The `local-pr-review` brain initiative and `ado-pr.nvim` (a Neovim ADO PR plugin folding steps 2–3 + and 7 into the editor: `:AdoPr` pick, `:AdoPrComment`, `:AdoPrVote`). diff --git a/nvim/lua/config/diffview.lua b/nvim/lua/config/diffview.lua index 0d1a013..78b64fc 100644 --- a/nvim/lua/config/diffview.lua +++ b/nvim/lua/config/diffview.lua @@ -36,3 +36,10 @@ map('n', 'gm', function() end, { desc = 'Diffview: review branch vs default' }) map('n', 'gh', 'DiffviewFileHistory', { desc = 'Diffview: repo history' }) map('n', 'gH', 'DiffviewFileHistory %', { desc = 'Diffview: current file history' }) + +-- ado-pr.nvim (loaded in plugins.lua when the local checkout exists) layers ADO PR +-- pick/checkout/comment/vote on top of diffview. In the diff: / cycle files, +-- ]c/[c jump hunks (diffview defaults — the stepping keys for a PR review). +if pcall(require, 'ado-pr') then + map('n', 'ga', 'AdoPr', { desc = 'ado-pr: pick an active PR to review' }) +end diff --git a/nvim/lua/config/plugins.lua b/nvim/lua/config/plugins.lua index 8060790..5b1ff15 100644 --- a/nvim/lua/config/plugins.lua +++ b/nvim/lua/config/plugins.lua @@ -50,3 +50,12 @@ local specs = { -- config modules (treesitter, lsp, ui, gitsigns) can use the plugins. -- confirm=false: installs silently on a fresh machine. vim.pack.add(specs, { load = true, confirm = false }) + +-- ado-pr.nvim (local checkout, no git remote yet): loaded via rtp prepend instead of a +-- vim.pack spec so the machine-absolute path never enters nvim-pack-lock.json; its +-- plugin/ file is sourced at startup like any rtp plugin, and the guard makes it inert +-- on machines without the checkout. Switch to vim.pack once it has a remote. +local ado_pr = 'E:/Personal Projects/ado-pr.nvim' +if vim.fn.isdirectory(ado_pr) == 1 then + vim.opt.rtp:prepend(ado_pr) +end diff --git a/powershell/Microsoft.PowerShell_profile.ps1 b/powershell/Microsoft.PowerShell_profile.ps1 index b8c1171..68aa578 100644 --- a/powershell/Microsoft.PowerShell_profile.ps1 +++ b/powershell/Microsoft.PowerShell_profile.ps1 @@ -609,6 +609,54 @@ function Initialize-DeferredProfileSecondary { } Remove-Item $completion_file, Env:\_ARGCOMPLETE_STDOUT_FILENAME, Env:\ARGCOMPLETE_USE_TEMPFILES, Env:\COMP_LINE, Env:\COMP_POINT, Env:\_ARGCOMPLETE, Env:\_ARGCOMPLETE_SUPPRESS_SPACE, Env:\_ARGCOMPLETE_IFS, Env:\_ARGCOMPLETE_SHELL } + + # Local ADO PR review (the review-worktree workflow): fzf-pick an active PR — or pass + # -Id — hop to the repo's standing detached `review` worktree, and hand off to nvim + # (ado-pr.nvim checks out the PR and opens diffview). Run from inside the target repo; + # az auto-detects org/project/repo from the remote. + # global: — a bare `function` here would be local to Initialize-DeferredProfileSecondary. + function global:Invoke-AdoPrReview { + param([int]$Id) + + $commonDir = git rev-parse --path-format=absolute --git-common-dir 2>$null + if (-not $commonDir) { + Write-Error 'Invoke-AdoPrReview: not inside a git repository' + return + } + # The review worktree lives beside .bare (bare-worktree layout) — never review in main. + if ((Split-Path $commonDir -Leaf) -ne '.bare') { + Write-Error "Invoke-AdoPrReview: needs the bare-worktree layout (git common dir is '$commonDir', expected a '.bare')" + return + } + + if (-not $Id) { + $prs = az repos pr list --status active --query '[].[pullRequestId, title, createdBy.displayName]' -o tsv + if ($LASTEXITCODE -ne 0) { + Write-Error "Invoke-AdoPrReview: 'az repos pr list' failed (exit $LASTEXITCODE) — is az logged in and the remote an ADO repo?" + return + } + # An empty fzf pick is a deliberate cancel — return silently. + $picked = $prs | fzf --prompt 'ado pr> ' --height 40% --reverse + if (-not $picked) { return } + $Id = [int]($picked -split "`t")[0] + } + + $reviewDir = Join-Path (Split-Path $commonDir) 'review' + if (-not (Test-Path $reviewDir)) { + git worktree add --detach $reviewDir + if ($LASTEXITCODE -ne 0) { + Write-Error "Invoke-AdoPrReview: 'git worktree add --detach $reviewDir' failed (exit $LASTEXITCODE)" + return + } + } + if (git -C $reviewDir status --porcelain) { + Write-Error "Invoke-AdoPrReview: review worktree '$reviewDir' is dirty — clean it before checking out a PR" + return + } + Set-Location $reviewDir + nvim "+AdoPrReview $Id" + } + Set-Alias -Name prr -Value Invoke-AdoPrReview -Scope Global } if (Get-Command -Name zellij -ErrorAction Ignore) { Register-ArgumentCompleter -Native -CommandName zellij -ScriptBlock { diff --git a/tests/ado-pr-review.Tests.ps1 b/tests/ado-pr-review.Tests.ps1 new file mode 100644 index 0000000..ea9ca34 --- /dev/null +++ b/tests/ado-pr-review.Tests.ps1 @@ -0,0 +1,95 @@ +#Requires -Version 7 +# Pester tests for Invoke-AdoPrReview (prr) in powershell/Microsoft.PowerShell_profile.ps1: +# the bare-worktree resolution + guards, review-worktree creation, and the nvim handoff. +# The profile runs side effects at load and is not dot-sourceable, so the function is +# lifted out by AST (same pattern as fzf-pickers.Tests.ps1). az/fzf are never reached +# when -Id is passed; nvim is replaced by a PATH shim that records its arguments, so no +# editor launches. The interactive az/fzf discovery path is not covered here — it needs +# a real ADO remote and a logged-in az. + +BeforeAll { + $profilePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'powershell' 'Microsoft.PowerShell_profile.ps1' + $ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$null, [ref]$null) + $fn = $ast.FindAll( + { param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | + Where-Object { $_.Name -match 'Invoke-AdoPrReview' } + . ([scriptblock]::Create($fn.Extent.Text)) + + # nvim shim: records its args instead of launching an editor. + $script:shimDir = Join-Path $TestDrive 'shims' + $script:nvimArgsFile = Join-Path $TestDrive 'nvim-args.txt' + New-Item -ItemType Directory -Path $script:shimDir | Out-Null + Set-Content -Path (Join-Path $script:shimDir 'nvim.cmd') -Value "@echo %*> `"$script:nvimArgsFile`"" + $script:origPath = $env:PATH + $env:PATH = $script:shimDir + [IO.Path]::PathSeparator + $env:PATH + + # Bare-worktree fixture: /.bare + /main, cloned from a seed repo. + $seed = Join-Path $TestDrive 'seed' + git init -b main $seed 2>&1 | Out-Null + Set-Content -Path (Join-Path $seed 'file.txt') -Value 'seed' + git -C $seed add file.txt 2>&1 | Out-Null + git -C $seed -c user.name=test -c user.email=test@test commit -m seed 2>&1 | Out-Null + $script:layout = Join-Path $TestDrive 'layout' + git clone --bare $seed (Join-Path $script:layout '.bare') 2>&1 | Out-Null + git -C (Join-Path $script:layout '.bare') worktree add ../main main 2>&1 | Out-Null +} + +AfterAll { + $env:PATH = $script:origPath + # git pack/object files are read-only, which Pester's TestDrive cleanup cannot delete. + Get-ChildItem $TestDrive -Recurse -Force -File | ForEach-Object { $_.IsReadOnly = $false } +} + +Describe 'Invoke-AdoPrReview' { + BeforeEach { + Push-Location $TestDrive + } + + AfterEach { + # The success path Set-Locations into the review worktree — restore for the next test. + Pop-Location + } + + Context 'guards' { + It 'errors outside a git repository' { + $bare = New-Item -ItemType Directory -Path (Join-Path $TestDrive 'not-a-repo') -Force + Set-Location $bare + $out = Invoke-AdoPrReview -Id 1 2>&1 + $out | Should -Match 'not inside a git repository' + } + + It 'errors in a normal (non-bare-layout) clone' { + Set-Location (Join-Path $TestDrive 'seed') + $out = Invoke-AdoPrReview -Id 1 2>&1 + $out | Should -Match 'bare-worktree layout' + } + } + + Context 'success path' { + It 'creates the detached review worktree beside .bare and hands off to nvim' { + Set-Location (Join-Path $script:layout 'main') + Invoke-AdoPrReview -Id 42 + + $reviewDir = Join-Path $script:layout 'review' + Test-Path $reviewDir | Should -BeTrue + (git -C $reviewDir rev-parse --path-format=absolute --git-common-dir) | + Should -Be ((Join-Path $script:layout '.bare') -replace '\\', '/') + (Get-Location).Path | Should -Be $reviewDir + (Get-Content $script:nvimArgsFile) | Should -Match '\+AdoPrReview 42' + } + + It 'errors instead of checking out when the review worktree is dirty' { + # Self-sufficient under test filtering: ensure the review worktree exists even if + # the creation test above did not run. + if (-not (Test-Path (Join-Path $script:layout 'review'))) { + git -C (Join-Path $script:layout '.bare') worktree add --detach ../review 2>&1 | Out-Null + } + Set-Content -Path (Join-Path $script:layout 'review' 'file.txt') -Value 'dirty' + Set-Location (Join-Path $script:layout 'main') + if (Test-Path $script:nvimArgsFile) { Remove-Item $script:nvimArgsFile } + $out = Invoke-AdoPrReview -Id 42 2>&1 + $out | Should -Match 'dirty' + Test-Path $script:nvimArgsFile | Should -BeFalse + } + } +} From eee9afdd4ca0fca06ccd41f53bda30d77e852fb4 Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Thu, 23 Jul 2026 22:36:26 +1000 Subject: [PATCH 2/3] perf(powershell): prr discovery via ADO REST with a cached bearer token az repos pr list cost ~2-3s of az cold start on every prr call. Discovery now parses the origin remote (ConvertFrom-AdoRemoteUrl: https-with-userinfo, plain https, and ssh v3 forms, %-unescaped) and lists active PRs over REST with a session-cached bearer token (Get-AdoAccessToken; az only on cache miss, token lives ~1h). Verified live against T2.ServiceCatalogue: token cold 0.8s / cached 6ms, PR list 173ms. Parser is Pester-covered (4 tests, RED first). --- CLAUDE.md | 2 +- powershell/Microsoft.PowerShell_profile.ps1 | 68 +++++++++++++++++++-- tests/ado-pr-review.Tests.ps1 | 36 ++++++++++- 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 26ff3fb..8cda56c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ The PowerShell profile (`powershell/Microsoft.PowerShell_profile.ps1`) is a sing 1. **Phase 1 (blocking, must stay cheap)**: module-availability cache (single `PSModulePath` scan into `$global:ProfileModules`), PSReadLine with `PredictionSource History` only, keybindings, dot-source `Profile/Set-Prompt.ps1` (which defines but no longer *calls* `Initialize-AzTimer`), aliases, tool wrapper functions (`y` for yazi). 2. **Phase 2a (first idle)**: `Initialize-DeferredProfile` loads PSFzf and zoxide, upgrades PSReadLine to `HistoryAndPlugin`, sets the fd-backed `FZF_*_COMMAND` vars, defines the eza `ll`/`la`/`lt` helpers, and calls `Initialize-AzTimer` (Az context timer — `runspace.Open()` + first eventing call ~200ms, moved off the load path). Guarded by `$global:ProfileDeferredDone`. These are the interactive tools needed immediately after the prompt appears. -3. **Phase 2b (next idle)**: `Initialize-DeferredProfileSecondary` registers the az + zellij native tab-completers, defines the `prr`/`Invoke-AdoPrReview` ADO PR review helper (az-gated: fzf PR picker → detached `review` worktree → `nvim "+AdoPrReview "`, pairing with ado-pr.nvim), and loads git-completion (+ `g` alias completer) and WinGet CommandNotFound. Guarded by `$global:ProfileDeferredSecondaryDone`. Split from 2a so the first keypress isn't blocked by their import cost. +3. **Phase 2b (next idle)**: `Initialize-DeferredProfileSecondary` registers the az + zellij native tab-completers, defines the `prr`/`Invoke-AdoPrReview` ADO PR review helper (az-gated: REST-backed fzf PR picker — `ConvertFrom-AdoRemoteUrl` + a session-cached az bearer token (`Get-AdoAccessToken`), ~0.2s warm vs ~2-3s for `az repos pr list` — → detached `review` worktree → `nvim "+AdoPrReview "`, pairing with ado-pr.nvim), and loads git-completion (+ `g` alias completer) and WinGet CommandNotFound. Guarded by `$global:ProfileDeferredSecondaryDone`. Split from 2a so the first keypress isn't blocked by their import cost. 4. **Phase 3 (async runspace)**: Az context refresh on a 60s timer. The timer is created in Phase 2a (`Initialize-AzTimer`); the refresh itself runs in a background runspace wired up in `Set-Prompt.ps1`. When adding to the profile, classify by cost first — anything that loads .NET assemblies or scans the filesystem belongs in Phase 2, not Phase 1. Use the `$global:ProfileModules` cache rather than calling `Get-Module -ListAvailable` again. diff --git a/powershell/Microsoft.PowerShell_profile.ps1 b/powershell/Microsoft.PowerShell_profile.ps1 index 68aa578..89f5001 100644 --- a/powershell/Microsoft.PowerShell_profile.ps1 +++ b/powershell/Microsoft.PowerShell_profile.ps1 @@ -612,9 +612,47 @@ function Initialize-DeferredProfileSecondary { # Local ADO PR review (the review-worktree workflow): fzf-pick an active PR — or pass # -Id — hop to the repo's standing detached `review` worktree, and hand off to nvim - # (ado-pr.nvim checks out the PR and opens diffview). Run from inside the target repo; - # az auto-detects org/project/repo from the remote. + # (ado-pr.nvim checks out the PR and opens diffview). Run from inside the target repo. + # Discovery goes over ADO REST with a cached az bearer token — `az repos pr list` + # costs ~2-3s of az cold start on every call, Invoke-RestMethod a few hundred ms. # global: — a bare `function` here would be local to Initialize-DeferredProfileSecondary. + + # Parse an Azure DevOps remote URL into org/project/repo; $null when not ADO. + function global:ConvertFrom-AdoRemoteUrl { + param([string]$Url) + $patterns = + '^https://(?:[^@/]+@)?dev\.azure\.com/([^/]+)/([^/]+)/_git/([^/]+?)/?$', + '^git@ssh\.dev\.azure\.com:v3/([^/]+)/([^/]+)/([^/]+?)/?$' + foreach ($p in $patterns) { + if ($Url -match $p) { + return [pscustomobject]@{ + Organization = [uri]::UnescapeDataString($Matches[1]) + Project = [uri]::UnescapeDataString($Matches[2]) + Repository = [uri]::UnescapeDataString($Matches[3]) + } + } + } + } + + # Bearer token for ADO REST, cached for the session — `az account get-access-token` + # costs ~2s; the token lives ~1h. 499b84ac-… is the well-known ADO resource id. + function global:Get-AdoAccessToken { + if ($global:__AdoAccessToken -and $global:__AdoAccessToken.Expires -gt [DateTimeOffset]::UtcNow.AddMinutes(5)) { + return $global:__AdoAccessToken.Token + } + $raw = az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --output json + if ($LASTEXITCODE -ne 0) { + Write-Error "Get-AdoAccessToken: 'az account get-access-token' failed (exit $LASTEXITCODE) — is az logged in?" + return + } + $tok = $raw | ConvertFrom-Json + # expires_on (unix epoch) on current az; expiresOn (local time) is the legacy field. + $expires = if ($tok.expires_on) { [DateTimeOffset]::FromUnixTimeSeconds([long]$tok.expires_on) } + else { [DateTimeOffset](Get-Date $tok.expiresOn) } + $global:__AdoAccessToken = @{ Token = $tok.accessToken; Expires = $expires } + $global:__AdoAccessToken.Token + } + function global:Invoke-AdoPrReview { param([int]$Id) @@ -630,13 +668,31 @@ function Initialize-DeferredProfileSecondary { } if (-not $Id) { - $prs = az repos pr list --status active --query '[].[pullRequestId, title, createdBy.displayName]' -o tsv - if ($LASTEXITCODE -ne 0) { - Write-Error "Invoke-AdoPrReview: 'az repos pr list' failed (exit $LASTEXITCODE) — is az logged in and the remote an ADO repo?" + $remote = git remote get-url origin 2>$null + $repoInfo = ConvertFrom-AdoRemoteUrl -Url $remote + if (-not $repoInfo) { + Write-Error "Invoke-AdoPrReview: origin remote '$remote' is not an Azure DevOps remote" + return + } + $token = Get-AdoAccessToken + if (-not $token) { return } + $uri = 'https://dev.azure.com/{0}/{1}/_apis/git/repositories/{2}/pullrequests?searchCriteria.status=active&api-version=7.1' -f + $repoInfo.Organization, + [uri]::EscapeDataString($repoInfo.Project), + [uri]::EscapeDataString($repoInfo.Repository) + try { + $prs = (Invoke-RestMethod -Uri $uri -Headers @{ Authorization = "Bearer $token" } -ErrorAction Stop).value + } catch { + Write-Error "Invoke-AdoPrReview: PR list request failed for '$uri': $($_.Exception.Message)" + return + } + if (-not $prs) { + Write-Host "no active PRs in $($repoInfo.Repository)" return } # An empty fzf pick is a deliberate cancel — return silently. - $picked = $prs | fzf --prompt 'ado pr> ' --height 40% --reverse + $picked = $prs | ForEach-Object { "$($_.pullRequestId)`t$($_.title)`t$($_.createdBy.displayName)" } | + fzf --prompt 'ado pr> ' --height 40% --reverse if (-not $picked) { return } $Id = [int]($picked -split "`t")[0] } diff --git a/tests/ado-pr-review.Tests.ps1 b/tests/ado-pr-review.Tests.ps1 index ea9ca34..0287dd0 100644 --- a/tests/ado-pr-review.Tests.ps1 +++ b/tests/ado-pr-review.Tests.ps1 @@ -10,10 +10,13 @@ BeforeAll { $profilePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'powershell' 'Microsoft.PowerShell_profile.ps1' $ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$null, [ref]$null) - $fn = $ast.FindAll( + $wanted = 'Invoke-AdoPrReview', 'ConvertFrom-AdoRemoteUrl' + $funcs = $ast.FindAll( { param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | - Where-Object { $_.Name -match 'Invoke-AdoPrReview' } - . ([scriptblock]::Create($fn.Extent.Text)) + Where-Object { $fn = $_.Name; $wanted | Where-Object { $fn -match $_ } } + foreach ($fn in $funcs) { + . ([scriptblock]::Create($fn.Extent.Text)) + } # nvim shim: records its args instead of launching an editor. $script:shimDir = Join-Path $TestDrive 'shims' @@ -40,6 +43,33 @@ AfterAll { Get-ChildItem $TestDrive -Recurse -Force -File | ForEach-Object { $_.IsReadOnly = $false } } +Describe 'ConvertFrom-AdoRemoteUrl' { + It 'parses the https-with-userinfo form and unescapes names' { + $r = ConvertFrom-AdoRemoteUrl -Url 'https://MyOrg@dev.azure.com/MyOrg/My%20Project/_git/My.Repo' + $r.Organization | Should -Be 'MyOrg' + $r.Project | Should -Be 'My Project' + $r.Repository | Should -Be 'My.Repo' + } + + It 'parses the plain https form' { + $r = ConvertFrom-AdoRemoteUrl -Url 'https://dev.azure.com/MyOrg/Proj/_git/Repo' + $r.Organization | Should -Be 'MyOrg' + $r.Project | Should -Be 'Proj' + $r.Repository | Should -Be 'Repo' + } + + It 'parses the ssh v3 form' { + $r = ConvertFrom-AdoRemoteUrl -Url 'git@ssh.dev.azure.com:v3/MyOrg/Proj/Repo' + $r.Organization | Should -Be 'MyOrg' + $r.Project | Should -Be 'Proj' + $r.Repository | Should -Be 'Repo' + } + + It 'returns nothing for a non-ADO remote' { + ConvertFrom-AdoRemoteUrl -Url 'git@github.com:user/repo.git' | Should -BeNullOrEmpty + } +} + Describe 'Invoke-AdoPrReview' { BeforeEach { Push-Location $TestDrive From 3c496119f802f6b18b8acb53e3074fcf9a8b186d Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Thu, 23 Jul 2026 22:55:33 +1000 Subject: [PATCH 3/3] fix(tests): make ado-pr-review suite CI-proof GitHub Actions' pwsh shell sets ErrorActionPreference=Stop, turning the guards' Write-Error terminating before the 2>&1 capture - pin Continue in BeforeAll (verified locally under Stop). The nvim shim was Windows-only (.cmd); the Ubuntu job runs this suite too, so write an executable sh shim there instead. --- tests/ado-pr-review.Tests.ps1 | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/ado-pr-review.Tests.ps1 b/tests/ado-pr-review.Tests.ps1 index 0287dd0..dde2eff 100644 --- a/tests/ado-pr-review.Tests.ps1 +++ b/tests/ado-pr-review.Tests.ps1 @@ -8,6 +8,11 @@ # a real ADO remote and a logged-in az. BeforeAll { + # GitHub Actions' pwsh shell runs with $ErrorActionPreference = 'Stop', which turns the + # function's Write-Error guards terminating before the assertions can capture them via + # 2>&1. Pin the default so local runs and CI behave identically. + $ErrorActionPreference = 'Continue' + $profilePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'powershell' 'Microsoft.PowerShell_profile.ps1' $ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$null, [ref]$null) $wanted = 'Invoke-AdoPrReview', 'ConvertFrom-AdoRemoteUrl' @@ -18,11 +23,18 @@ BeforeAll { . ([scriptblock]::Create($fn.Extent.Text)) } - # nvim shim: records its args instead of launching an editor. + # nvim shim: records its args instead of launching an editor (.cmd on Windows, + # executable sh script on Linux — CI runs this suite on both). $script:shimDir = Join-Path $TestDrive 'shims' $script:nvimArgsFile = Join-Path $TestDrive 'nvim-args.txt' New-Item -ItemType Directory -Path $script:shimDir | Out-Null - Set-Content -Path (Join-Path $script:shimDir 'nvim.cmd') -Value "@echo %*> `"$script:nvimArgsFile`"" + if ($IsWindows) { + Set-Content -Path (Join-Path $script:shimDir 'nvim.cmd') -Value "@echo %*> `"$script:nvimArgsFile`"" + } else { + $shim = Join-Path $script:shimDir 'nvim' + Set-Content -Path $shim -Value "#!/bin/sh`nprintf '%s ' `"`$@`" > '$script:nvimArgsFile'" + chmod +x $shim + } $script:origPath = $env:PATH $env:PATH = $script:shimDir + [IO.Path]::PathSeparator + $env:PATH