Skip to content

Add az-Find-AzDevOpsText fuzzy search over cached hierarchy#153

Open
jdschleicher wants to merge 3 commits into
mainfrom
claude/azure-hierarchy-fuzzy-search-44eyxw
Open

Add az-Find-AzDevOpsText fuzzy search over cached hierarchy#153
jdschleicher wants to merge 3 commits into
mainfrom
claude/azure-hierarchy-fuzzy-search-44eyxw

Conversation

@jdschleicher

@jdschleicher jdschleicher commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Contents

Section Status
Summary
Issue No linked issue
Changes ✅ 6 files
Existing-user note
Test Plan 0/5
Skill Reports
🐚 Bash Engineer Review ✅ APPROVE — View
💠 PowerShell Engineer Review ✅ APPROVE — View
🛡️ Security Audit ✅ PASS — View
🧼 Clean-Code Engineer Review ✅ Resolved in 4727cae (was REQUEST CHANGES) — View
📐 AzDO Diagrams Check ✅ Synced in 341ecff

How it works

az-Find-AzDevOpsText reads the existing cached hierarchy (now carrying an HTML-stripped Description per item), applies the shared -IncludeClosed gate, then filters by an all-tokens-must-match search over Title + Description. Hits are projected (with a truncated Description snippet) into Out-ConsoleGridView, whose own live filter narrows further; selecting a row hands off to the standard open / create-child action.

flowchart TD
    Entry(["az-Find-AzDevOpsText<br/>[query] [-IncludeClosed]"]):::pub

    Read[Read-AzDevOpsHierarchyCache]:::priv
    Hier[(hierarchy.json)]:::io
    Conv[ConvertFrom-AzDevOpsHierarchyItem]:::priv
    Html[ConvertFrom-AzDevOpsHtmlText<br/>strip HTML to plain Description]:::priv

    Tip[Write-AzDevOpsNoDescriptionTip]:::priv
    Visible[Get-AzDevOpsVisibleItems<br/>-IncludeClosed gate]:::priv
    Match[Select-AzDevOpsTextMatches<br/>all tokens in Title+Description]:::priv
    Empty{matches?}
    NoHits([print no-matches, return]):::priv

    Snip[Format-AzDevOpsTextSnippet<br/>Description column]:::priv
    Grid[Show-AzDevOpsRows -PassThru<br/>Out-ConsoleGridView live filter]:::priv
    Action[Invoke-AzDevOpsRowAction<br/>open / create child]:::priv

    Entry --> Read --> Hier
    Read --> Conv --> Html
    Entry --> Tip
    Entry --> Visible --> Match --> Empty
    Empty -- no --> NoHits
    Empty -- yes --> Snip --> Grid --> Action

    classDef pub fill:#1f3a5f,stroke:#4ea3ff,color:#fff
    classDef priv fill:#3a3a3a,stroke:#999,color:#fff
    classDef io fill:#5a4a1a,stroke:#e0c060,color:#fff
Loading

Summary

Adds az-Find-AzDevOpsText — a quick free-text/fuzzy filter over every work item in the cached hierarchy (Epics, Features, and the requirement tier: User Story / PBI / Requirement / Issue), searching Title + Description.

  • az-Find-AzDevOpsText "deploy pipeline" — the query splits into whitespace tokens; an item matches when all tokens appear (case-insensitive substring) in its Title or Description. Hits render in Out-ConsoleGridView.
  • az-Find-AzDevOpsText (no query) — dumps the whole hierarchy into the grid so its own live-filter box searches Title + Description as you type.
  • az-Find-AzDevOpsText -IncludeClosed auth — include Closed/Removed items.
  • Selecting a row reuses the shared open-in-browser / create-child action, exactly like the other az-Show-* views.

Issue

No tracked issue — direct feature request via /next-task.

Changes

  • powcuts_by_cli/azdevops_find.ps1 — new public az-Find-AzDevOpsText + private helpers Select-AzDevOpsTextMatches, Format-AzDevOpsTextSnippet, Write-AzDevOpsNoDescriptionTip, and the shared Get-AzDevOpsVisibleItems -IncludeClosed gate (now also used by az-Find-AzDevOpsWorkItem).
  • powcuts_by_cli/azdevops_views.ps1 — new ConvertFrom-AzDevOpsHtmlText (strips HTML from descriptions); Description field added to ConvertFrom-AzDevOpsHierarchyItem.
  • powcuts_by_cli/azdevops_paths.ps1[System.Description] added to the three seeded hierarchy WIQLs (epics / features / user-stories).
  • powcuts_by_cli/azdevops_help.ps1az-help catalog entry (DailyRead).
  • powcuts_by_cli/azdevops_auth.ps1 + README.md — verb matrix + usage docs.
  • docs/azure-devops-diagrams.md — architecture + dependency-map nodes/edges for the new surface.

PowerShell-only — the AzDevOps surface has no bash counterpart.

Existing-user note

Description search needs [System.Description] in the hierarchy WIQLs. It now ships in the seeded defaults, but users whose *.wiql files were seeded earlier must run az-Open-HierarchyWiqls, add [System.Description] to each SELECT, and re-run az-Sync-AzDevOpsCache. The command prints a one-line tip when it notices no descriptions are cached. Title-only search works regardless.

Test Plan

  • Open a fresh PowerShell terminal — files dot-source without parse errors
  • az-Find-AzDevOpsText + tab-tab autocompletes under the az-Find- prefix
  • az-Sync-AzDevOpsCache then az-Find-AzDevOpsText — grid opens with the full hierarchy + a Description column; the filter box narrows live
  • az-Find-AzDevOpsText "<word in a title>" — only matching items show; picking one fires the open/create-child prompt
  • After adding [System.Description] to the WIQLs + re-sync — a word found only in a description matches

🤖 Generated with Claude Code

https://claude.ai/code/session_01AQoEMfuDKdoS5DKTcAcXUu

Adds az-Find-AzDevOpsText: a quick free-text filter over every work
item in the cached hierarchy (Epics, Features, requirement-tier),
searching Title + Description. A query splits into whitespace tokens
that must all match (case-insensitive substring); with no query the
full hierarchy goes into Out-ConsoleGridView so its own live-filter
box searches Title + Description. Selecting a row reuses the shared
open/create-child action like the other az-Show-* views.

To make descriptions searchable:
- add [System.Description] to the three seeded hierarchy WIQLs
- cache a cleaned, HTML-stripped Description on each hierarchy item
  (new ConvertFrom-AzDevOpsHtmlText helper + converter field)

Existing users with older seeded *.wiql files get a one-line tip to
add [System.Description] and re-sync; Title-only search works until
they do. Wired into az-help and the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQoEMfuDKdoS5DKTcAcXUu

Copy link
Copy Markdown
Owner Author

🐚 Senior Bash Engineer

Summary

No bash files changed in this PR. The true diff scope (git diff origin/main...HEAD) is 5 PowerShell files (powcuts_by_cli/azdevops_auth.ps1, azdevops_find.ps1, azdevops_help.ps1, azdevops_paths.ps1, azdevops_views.ps1) plus README.md. None match bashcuts_by_cli/* or .bcut_home, so the bash review surface is empty.

Findings

Severity Location Issue
No bash files in diff; nothing to review

Sourcing Wire-Up

N/A — no new files under bashcuts_by_cli/

Naming Convention

N/A — no bash aliases/functions added or changed

Verdict

APPROVE — no bash files in this diff


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

🛡️ Senior Security Engineer

Static review (pwsh unavailable) of the diff on claude/azure-hierarchy-fuzzy-search-44eyxw vs origin/main: 5 PowerShell files + README.md. Feature adds az-Find-AzDevOpsText free-text search over the cached hierarchy JSON, adds [System.Description] to three WIQL templates, and adds ConvertFrom-AzDevOpsHtmlText to strip HTML from cached descriptions.

Focus-area findings

1. WIQL injection via [System.Description] — NOT VULNERABLE.
The [System.Description] additions in azdevops_paths.ps1 are static column names in the SELECT clause of the three heredoc templates. They carry no user input. The only runtime substitution into WIQL happens in Get-AzDevOpsWiql for {{AZ_AREA}} and {{AZ_USER_EMAIL}}, both of which are single-quote-escaped ($env:AZ_AREA.Replace("'", "''")) before substitution. This change touches neither token nor the substitution code path. No new injection vector. (Note: the values are sourced from the user's own $env vars, not remote input, so even absent escaping the trust boundary is the local environment.)

2. ReDoS in ConvertFrom-AzDevOpsHtmlText — LOW / acceptable.
The three patterns — (?i)</(p|div|li|tr|h[1-6])>|<br\s*/?>, <[^>]+>, and \s+ — are all linear-time. None contains nested/overlapping quantifiers (no (a+)+, no (.*)*), which is the structural prerequisite for catastrophic backtracking. <[^>]+> uses a negated character class (cannot backtrack catastrophically); \s+ and \s* are simple. Worst case is linear in input length. Input is the cached System.Description HTML, which originates from your own Azure DevOps org (semi-trusted), and processing happens at cache-read time on bounded content. No ReDoS risk.

3. Decoded HTML execution — NOT VULNERABLE.
[System.Net.WebUtility]::HtmlDecode produces a plain string that is only ever (a) stored on the in-memory Description property, (b) substring-searched via .Contains() in Select-AzDevOpsTextMatches, and (c) truncated and rendered as a grid column by Out-ConsoleGridView. It is never passed to Invoke-Expression, &, ., Start-Process, or any format string that re-parses it. No code execution. (HtmlDecode does not interpret markup — it only resolves entities — so it cannot reintroduce tags that the strip pass removed in a way that matters here.)

4. Search haystack — confirmed inert.
"$($item.Title) $($item.Description)".ToLowerInvariant() then .Contains($token) is pure string interpolation + substring test. PowerShell "$(...)" does not evaluate the content of the interpolated values as code; it stringifies them. No expansion or execution of cached data. Query tokens come from $Query -split '\s+' and are likewise only used as .Contains() needles — no regex, no injection.

5. Browser-open sink — safe.
The grid Url column and Start-Process $url path build URLs from $_.Id, which ConvertFrom-AzDevOpsHierarchyItem casts to [int]. Description data never reaches Start-Process. No argument-injection surface.

6. Secrets / network / file writes — none introduced.
No new credentials, tokens, or secrets. No new network calls — az-Find-AzDevOpsText reads the existing ~/.bashcuts-az-devops-app/cache/hierarchy.json via Read-AzDevOpsHierarchyCache and never invokes az. No new file writes; no untrusted path construction. README + help-catalog changes are documentation only.

Verdict

No security issues found in this diff. The [System.Description] WIQL additions are static and inert; HTML stripping is linear-time and display/search-only; the search path performs no evaluation of cached or query data.

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

💠 Senior PowerShell Engineer

Summary

Reviewed the PR diff (git diff origin/main...HEAD) across five .ps1 files: a new free-text fuzzy search command az-Find-AzDevOpsText plus three private helpers in azdevops_find.ps1, a new ConvertFrom-AzDevOpsHtmlText normalizer and a Description field in ConvertFrom-AzDevOpsHierarchyItem in azdevops_views.ps1, [System.Description] added to three default WIQLs in azdevops_paths.ps1, a help-catalog entry in azdevops_help.ps1, and a one-line comment-matrix update in azdevops_auth.ps1. The change is clean, follows the established patterns in the file, and binds correctly to every helper it calls. No blocking issues.

Findings

Severity Location Issue
LOW azdevops_find.ps1 (Format-AzDevOpsTextSnippet) $snippetMaxLen = 200; if the value were ever set below $ellipsis.Length (3), Substring(0, len-3) would throw. Not reachable today (200 is a named constant), so informational only — no fix required.
LOW azdevops_find.ps1 (az-Find-AzDevOpsText) No upfront Test-AzDevOpsGridAvailable gate like az-Find-AzDevOpsWorkItem has. This is intentional and correct here — Show-AzDevOpsRows degrades to Format-Table when the grid is unavailable, so the command stays useful. Noting only for contrast with the sibling function.

Automatic-Variable Shadow Audit

PASS. The $matches$hits rename is clean. Grepped the whole file for assignments to automatic variables ($matches, $input, $args, $error, $foreach, $switch, $this, $host, $_, $null, $true, $false) — zero matches. The new locals ($hits, $matched, $tokens, $haystack, $allPresent, $collapsed, $truncated, $snippetMaxLen, $ellipsis, $spaced, $noTags, $decoded, $blockBreakPattern, $tagPattern, $hasAnyDescription) are all safe non-automatic names.

ConvertFrom-AzDevOpsHtmlText Correctness

PASS. The pipeline is sound for best-effort display/search normalization (and the comment explicitly scopes it as such, not a full parser):

  • $blockBreakPattern = '(?i)</(p|div|li|tr|h[1-6])>|<br\s*/?>' — case-insensitive, converts block-close and <br>/<br/>/<br /> to spaces so adjacent words don't fuse. Correct.
  • $tagPattern = '<[^>]+>' strips remaining tags. Standard greedy-within-tag approach; acceptable for ADO-emitted markup.
  • [System.Net.WebUtility]::HtmlDecode(...) decodes entities (&amp;, &lt;, &#39;, etc.) — the right BCL call, available in PowerShell 5.1 and 7+, no add-type needed.
  • ($decoded -replace '\s+', ' ').Trim() collapses whitespace last, after decoding, which is the correct ordering (decoded entities like &nbsp;→NBSP are then normalized).
  • Null/whitespace guard returns '' up front, so $f.'System.Description' being absent in older cache rows is handled — Description becomes '', not $null, which keeps Select-AzDevOpsTextMatches's "$($item.Title) $($item.Description)" haystack clean.

Minor note (no action): <[^>]+> won't strip a literal < that isn't part of a tag and won't handle <script>/<style> bodies — irrelevant for ADO description fields, and the doc comment already disclaims full-parser behavior.

Parameter Binding

PASS. Every cross-function call binds to a real parameter:

  • Show-AzDevOpsRows -Rows $rows -Title $title -PassThru ✓ (-PassThru switch exists, returns the grid selection).
  • Invoke-AzDevOpsRowAction -Selected $selected ✓ (-Selected param exists; handles $null and multi-select internally).
  • Select-AzDevOpsTextMatches -Items ... -Query ..., Format-AzDevOpsTextSnippet -Text ..., Write-AzDevOpsNoDescriptionTip -Items ... ✓.
  • (Get-AzDevOpsTitleColumn) used inline in the Select-Object projection returns a calculated-property hashtable — identical to the established usage at azdevops_views.ps1:474/610/1232/1424/1517, including the backtick line-continuation form at 1424. The trailing-comma-after-hashtable in the catalog and the backtick block both match in-repo style and parse correctly.
  • Read-AzDevOpsHierarchyCache, Write-AzDevOpsStaleBanner, Get-AzDevOpsClosedStates, Get-AzDevOpsWorkItemUrlPrefix all exist and are called with the same signatures as az-Find-AzDevOpsWorkItem.

Output Streams (Write-Host vs Write-Output)

PASS. All three Write-Host uses emit user-facing status only — the no-description tip, the "(no work items …)" empty-result message, and (existing) banners — never data. The function's actual return value flows through Show-AzDevOpsRows/Invoke-AzDevOpsRowAction, not via Write-Host. Correct per CLAUDE.md.

Error Handling

PASS / N/A. The command reads only the local hierarchy JSON cache via Read-AzDevOpsHierarchyCache (which it null-checks: if ($null -eq $items) { return }) and never invokes az, git, gh, or any external process — so there's no $LASTEXITCODE to check. No destructive operations.

Profile Side Effects

PASS. Every top-level addition is a function definition or a $script:-scoped catalog/WIQL constant (the help entry and the WIQL strings). Nothing runs at dot-source time beyond definition. No new network calls, sleeps, or unconditional output.

Approved Verbs

PASS.

  • Select-AzDevOpsTextMatchesSelect
  • Format-AzDevOpsTextSnippetFormat
  • Write-AzDevOpsNoDescriptionTipWrite
  • ConvertFrom-AzDevOpsHtmlTextConvertFrom ✓ (and mirrors the sibling ConvertFrom-AzDevOpsHierarchyItem)
  • az-Find-AzDevOpsText → the project's established az-Verb-Noun public-surface convention; Find is approved. Consistent with az-Find-AzDevOpsWorkItem.

Naming / Style (CLAUDE.md mandates)

PASS. Every if/else is multi-line with each branch body on its own line (the $visibleItems, $scopeLabel, $titleScope, and Url-expression conditionals all follow the expanded K&R form). Magic values are named ($snippetMaxLen, $ellipsis, $blockBreakPattern, $tagPattern). Two blank lines separate top-level functions; one-blank-line grouping inside bodies. Full cmdlet names used throughout (Where-Object, ForEach-Object, Sort-Object, Select-Object) — no in-script aliases. Helpers follow the "private helper can use any verb but here they're approved anyway" guidance and keep the public surface (az-Find-AzDevOpsText) clean.

Dot-Sourcing Wire-Up

N/A — no new .ps1 files were added; all changes are to existing already-wired files. The Order = 11 help-catalog entry slots correctly after the 1–10 DailyRead entries with no collision.

Verdict

APPROVE — no blocking issues. Two LOW/informational notes only.


pwsh was unavailable in the review environment; parse-correctness was assessed by close reading against the established in-repo patterns rather than by running the parser. Recommend the author run bash/pwsh parse as the standard gate before merge.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

🧼 Senior Clean-Code Engineer

Summary

Five PowerShell files changed, all in the Azure DevOps surface. The new az-Find-AzDevOpsText free-text search in azdevops_find.ps1 is well-shaped overall: the search/snippet/tip logic is cleanly decomposed into three small private helpers (Select-AzDevOpsTextMatches, Format-AzDevOpsTextSnippet, Write-AzDevOpsNoDescriptionTip), magic values are named, and the multi-line if/else and switch conventions are honored. The one blocking issue is a named CLAUDE.md violation: the -IncludeClosed "active-items" filter is inlined identically in two functions in the same file, and a reusable helper for it (Select-AzDevOpsActiveItems) already exists in the codebase.

Findings

Severity Location Issue
HIGH azdevops_find.ps1:156-162 & azdevops_find.ps1:232-238 Extract-repeated-branches violation. The exact block $closedStates = Get-AzDevOpsClosedStates; $visibleItems = if ($IncludeClosed) { $items } else { $items | Where-Object { $_.State -notin $closedStates } } appears verbatim in both az-Find-AzDevOpsText and az-Find-AzDevOpsWorkItem. CLAUDE.md: "if the same if/case branch appears in two or more functions, lift it into a small private helper." A helper for exactly this already exists — Select-AzDevOpsActiveItems (azdevops_views.ps1:266-282), which does if ($State) { ... } else { ... -notin $closedStates }. Either reuse it (extend it to accept an -IncludeClosed/all path) or add a tiny Get-AzDevOpsVisibleItems -Items $items -IncludeClosed:$IncludeClosed wrapper and call it from both functions. The new function should not re-inline this; the pre-existing duplication in az-Find-AzDevOpsWorkItem is now compounded to a third copy of the -notin $closedStates predicate in this domain (azdevops_views.ps1:278, azdevops_create_pickers.ps1:255,259).
LOW azdevops_find.ps1:186-189, 191-201 The Description and Url columns are inline calculated-property hashtables, while the Title column is the shared Get-AzDevOpsTitleColumn helper. Not blocking — these projections currently have one caller, so inlining is the correct altitude per the premature-abstraction rule. Only worth extracting (Get-AzDevOpsUrlColumn) if a second view grows a Url column. Noting for awareness, not requesting change.

CLAUDE.md rule checks (the ones called out)

  • Named magic strings/numbers — PASS. $snippetMaxLen = 200, $ellipsis = '...' (find.ps1:74-75), and the regex patterns $blockBreakPattern / $tagPattern (views.ps1:684-685) are all hoisted into named locals with intent-revealing names and comments. Good.
  • Multi-line if/else as hashtable property value (Url column) — PASS. find.ps1:191-201 expands the if ($urlPrefix) { ... } else { '' } across its own lines inside the Expression block; no inline shorthand. The $scopeLabel (167-172) and $titleScope (203-208) conditionals likewise comply.
  • Switch/case multi-line expansion — N/A. No switch/case introduced in this diff.
  • Never return a function call directly — PASS. Select-AzDevOpsTextMatches returns @($matched) (named local), Format-AzDevOpsTextSnippet returns $collapsed/$truncated, ConvertFrom-AzDevOpsHtmlText returns $collapsed. The final Invoke-AzDevOpsRowAction -Selected $selected (find.ps1:212) is a void dispatch call, not a returned value — fine.
  • Breathing room — PASS. Two blank lines between every new top-level function in find.ps1 and views.ps1; one blank line separating logical groups inside bodies (e.g. before the $matched loop, before the final return). No 3+ blank-line runs.

Duplication Map

Pattern Callsites Proposed helper
-IncludeClosed → active-items filter (if $IncludeClosed { $items } else { -notin $closedStates }) az-Find-AzDevOpsText find.ps1:156-162, az-Find-AzDevOpsWorkItem find.ps1:232-238 reuse/extend Select-AzDevOpsActiveItems (views.ps1:266) or add Get-AzDevOpsVisibleItems -Items -IncludeClosed
bare State -notin $closedStates predicate views.ps1:278, create_pickers.ps1:255/259, + the two above same helper (domain-wide) — out of scope to fully consolidate here, but the new function should not add a copy

Function Sizes

Function Lines (body) Verdict
Select-AzDevOpsTextMatches ~25 OK — single responsibility (AND-of-tokens filter)
Format-AzDevOpsTextSnippet ~13 OK
Write-AzDevOpsNoDescriptionTip ~7 OK
ConvertFrom-AzDevOpsHtmlText ~14 OK
az-Find-AzDevOpsText ~70 OK as an orchestrator — fetch/filter/project/present are each delegated; only the inlined -IncludeClosed block (see HIGH) should leave the body

Verdict

REQUEST CHANGES — 1 HIGH finding (named CLAUDE.md extract-repeated-branches violation: the -IncludeClosed active-items block is inlined identically in az-Find-AzDevOpsText and az-Find-AzDevOpsWorkItem, and a reusable helper already exists). Everything else is well-factored; fixing the one extraction clears the review.


Generated by Claude Code

claude added 2 commits June 19, 2026 23:47
Addresses the clean-code review on PR #153: the closed-state filter
block was inlined in both az-Find-AzDevOpsText and az-Find-AzDevOpsWorkItem.
Lift it into one private helper both call, per CLAUDE.md's
extract-repeated-branches rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQoEMfuDKdoS5DKTcAcXUu
…ep map

Sync the diagram doc with the new free-text search surface: the public
az-Find-AzDevOpsText node (architecture + dependency map), its helpers
(Get-AzDevOpsVisibleItems, Select-AzDevOpsTextMatches,
Format-AzDevOpsTextSnippet, Write-AzDevOpsNoDescriptionTip), the new
ConvertFrom-AzDevOpsHtmlText normalizer off ConvertFrom-AzDevOpsHierarchyItem,
and route the existing az-Find-AzDevOpsWorkItem closed-state filter through
the shared Get-AzDevOpsVisibleItems helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQoEMfuDKdoS5DKTcAcXUu
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants