Compare commits

...

14 Commits

Author SHA1 Message Date
Gordon Lam (SH)
eff2f80af7 fix: address issue #30340 2026-02-04 20:37:35 -08:00
Gordon Lam
87c65f9eec docs(paste): add AI preview credit documentation (#45236)
docs(paste): add AI preview credit documentation

```markdown
## Summary of the Pull Request

Adds documentation clarifying that the "Show preview" setting for Paste with AI does not consume additional AI credits. The preview displays the same AI response that was already generated from a single API call, cached locally.

## PR Checklist

- [x] Closes: #32950
- [x] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass - N/A (documentation only)
- [ ] **Localization:** All end-user-facing strings can be localized - N/A (dev docs only)
- [x] **Dev docs:** Added/updated
- [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

This PR addresses the question raised in issue #32950 about whether enabling preview for Paste with AI costs extra AI quota.

Changes to `doc/devdocs/modules/advancedpaste.md`:
- Added new "Paste with AI Preview" section explaining:
  - The `ShowCustomPreview` setting behavior
  - Confirmation that preview does **not** consume additional AI credits
  - The implementation flow showing a single API call with local caching
  - Reference to `OptionsViewModel.cs` lines 702-717
- Added settings documentation table for `ShowCustomPreview`

Fixes #32950

## Validation Steps Performed

- Verified documentation renders correctly in Markdown preview
- Confirmed technical accuracy by referencing `OptionsViewModel.cs` implementation
```

---------

Co-authored-by: yeelam-gordon <yeelam-gordon@users.noreply.github.com>
2026-01-31 09:03:24 -08:00
Gordon Lam
971c7e9fba docs(settings-ui): update Advanced Paste OOBE description for AI features (#45233)
## Summary of the Pull Request

Updates the Advanced Paste OOBE (Out-of-Box Experience) description to
accurately reflect that AI features no longer require specifically an
OpenAI API key. The new text clarifies:
- Changed "markdown" to "Markdown" and "json" to "JSON" for proper
casing
- Replaced "100% opt-in and requires an Open AI key" with "opt-in AI
feature that can use an online or local language model endpoint"

This fixes the outdated description that still referenced OpenAI as the
only option.

## PR Checklist

- [x] Closes: #44044
- [x] **Communication:** Documentation/string fix, no core contributor
discussion needed
- [ ] **Tests:** N/A - string-only change
- [x] **Localization:** The updated string is in the localizable
Resources.resw file
- [ ] **Dev docs:** N/A
- [ ] **New binaries:** N/A
- [ ] **Documentation updated:** N/A

## Detailed Description of the Pull Request / Additional comments

The change updates
\src/settings-ui/Settings.UI/Strings/en-us/Resources.resw\ to fix the
\Oobe_AdvancedPaste.Description\ string that incorrectly stated AI
features require an OpenAI key.

## Validation Steps Performed

- Verified the string change is valid XML
- Confirmed the updated description accurately reflects current Advanced
Paste AI capabilities
2026-01-31 08:46:31 -08:00
Jaylyn Barbee
055c3011cc Documentation walking through important steps for writing a New PowerToy (#44242)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
This new document serves as a handy guide, packed with key details and
helpful tips to keep in mind when creating a new PowerToy.
2026-01-30 14:45:35 +01:00
leileizhang
2f7fc91956 Fix OOBE pages Launch buttons remain clickable when modules are disabled (#44736)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Fixes the issue where Launch/Open buttons on OOBE (Welcome to PowerToys)
pages remain clickable even when the corresponding module is disabled.

Added enabled state checks to the following OOBE pages:
- **OobeColorPicker** - checks `ModuleType.ColorPicker`
- **OobeEnvironmentVariables** - checks
`ModuleType.EnvironmentVariables`
- **OobeHosts** - checks `ModuleType.Hosts`
- **OobeRun** - checks `ModuleType.PowerLauncher`
- **OobeRegistryPreview** - checks `ModuleType.RegistryPreview`
- **OobeShortcutGuide** - checks `ModuleType.ShortcutGuide`

<img width="1538" height="239" alt="image"
src="https://github.com/user-attachments/assets/da20628e-9c82-4619-8a5c-4b75a22b6901"
/>

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #44737
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-30 14:48:09 +08:00
Kai Tao
6d4f56cd83 Always on top: Add transparent support for on topped window (#44815)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Transparency support (best-effort)
> Not every window can be made transparent. Transparency is applied on a
best-effort basis and depends on how the target app/window is built and
rendered.

## When it may not work
* Windows with special rendering pipelines (e.g., certain
hardware-accelerated / compositor-managed surfaces).
* Some tool/popup/owned windows where the foreground window isn’t the
actual surface being drawn.

## How it works (high-level)
* Resolve the best target window (preferring the top-level/root window
over transient children).
* Apply Windows’ standard layered-window alpha mechanism (per-window
opacity) to adjust transparency.
* When unpinned, Restore the original opacity/state when possible.

If transparency doesn’t change, it means the window doesn’t support this
mechanism in its current configuration.
<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [X] Closes: 
#43278 
#42929
#28773

<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed


https://github.com/user-attachments/assets/c97a87f2-3126-4e19-990f-8c684dbeb631

<img width="1119" height="426" alt="image"
src="https://github.com/user-attachments/assets/547671ee-81d3-4c94-8199-bf0c4b1b7760"
/>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-29 13:48:27 +08:00
Jiří Polášek
4986915dae CmdPal: Batch ViewModel property change notifications (#44545)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

This PR introduces batching for property change notifications emitted by
Command Palette view models. It also adds a secondary notification path
that is guaranteed to execute on a background thread.

- Introduces **`BatchUpdateManager`**, which batches
`INotifyPropertyChanged` events from view models and replays them in a
coordinated way.
- Slightly reduces UI thread contention and allows related UI updates to
be applied together, reducing visual "tearing" in list items (when
title, subtitle and icon are updated separately with slight delay).
Batching won't mitigate all occurences, but its good enough and works
auto-magically.
- Adds a complementary background notification event that:
  - Is guaranteed to run on a background thread.
  - Fires before UI-thread notifications.
- Allows consumers to attach handlers without blocking COM out-of-proc
objects.

- Updates `TopLevelViewModel` to subscribe to the background property
change event instead of the UI-thread one.
- This avoids unintentionally shifting work onto the UI thread and
re-triggering expensive operations there.
- Previously, because `TopLevelViewModel` wraps another view model and
our view models raise `INPC` on the UI thread by default, its handler
was executing on the UI thread and re-raising the event as
`IListItem.PropertyChanged`, causing `FetchProperty` methods to run on
the UI thread again.
- Ideally, `TopLevelViewModel` should be reworked to address this more
cleanly, but that turned out to be a non-trivial change. This PR applies
a targeted mitigation in the meantime.








<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-28 21:56:11 -06:00
Jiří Polášek
cc2dce8816 CmdPal: replace custom fuzzy matching in Window Walker (#44807)
## Summary of the Pull Request

This PR replaces the custom search controller and fuzzy matching with
standard classes from the Extension SDK Toolkit.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-28 21:23:50 -06:00
Jiří Polášek
0de2af77ac CmdPal: Make Calculator Great Again (#44594)
## Summary of the Pull Request

This PR continues the tradition of alphabetical progress. After
[MBGA](#41961), we move on to **MCBA — Make Calculator Better Again!**

- Introduces limited automatic correction and completion of expressions.
- The goal is to allow uninterrupted typing and avoid disruptions when a
partially entered expression is temporarily invalid (which previously
caused the result to be replaced by an error message or hidden by the
fallback).
  - The implementation intentionally aims for a sweet spot:
    - Ignores trailing binary operators.
    - Automatically closes all opened parentheses.
- It is not exhaustive; for example, incomplete constants or functions
may still result in an invalid query.
- Copy current result to the search bar.
- Adds an option to copy the current result to the search bar when the
user types `=` at the end of the expression.
  - Adds a new menu item for the same action.
  - Fixes the **Save** command to also copy the result to the query.
- Adds support for the `factorial(x)` function and the `x!` expression.
- Factorial calculations are supported up to `170!` (limited by
`double`), but display is constrained by decimal conversion and allows
direct display of results up to `20!`.
- Adds support for the `sign(x)` function.
- Adds support for the `π` symbol as an alternative to the `pi`
constant.
- Adds a context menu item to the result list item and fallback that
displays the octal representation of the result.
- Implements beautification of the query:
- Converts technical symbols such as `*` or `/` to `×` or `÷`,
respectively.
- Not enabled for fallbacks for now, since the item text should match
the query to keep the score intact.
- Implements additional normalization of symbols in the query:
  - Percent: `%`, `%`, `﹪`
  - Minus: `−`, `-`, `–`, `—`
  - Factorial: `!`, `!`
- Multiplication: `*`, `×`, `∗`, `·`, `⋅`, `✕`, `✖`, `\u2062` (invisible
times)
  - Division: `/`, `÷`, ``, `:`
- Allows use of `²` and `³` as alternatives to `^2` and `^3`.
- Updates the unit test that was culture sensitive to force en-US output
(not an actual fix, but at least it clears false positive for now)
- Fixes pre-parsing of scientific notation to prevent capturing minus
sign as part of it.
- Fixes normalization/rounding of the result, so it can display small
values (the current solution turned it into a string with scientific
notation and couldn't parse it back).
- Updates test with new cases

## Pictures? Moving!

Previous behavior:


https://github.com/user-attachments/assets/ebcdcd85-797a-44f9-a8b1-a0f2f33c6b42

New behavior:


https://github.com/user-attachments/assets/5bd94663-a0d0-4d7d-8032-1030e79926c3





<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #43481
- [x] Closes: #43460
- [x] Closes: #42078
- [x] Closes: #41839
- [x] Closes: #39659
- [x] Closes: #40502
- [x] Related to: #41715
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-28 21:23:39 -06:00
Jiří Polášek
4694e99477 CmdPal: Upgrade FuzzyStringMatcher in the Command Palette Extensions SDK (#44809)
## Summary of the Pull Request

This PR upgrades the `FuzzyStringMatcher` used in the Command Palette
Extensions SDK with a focus on performance, memory efficiency, and
improved matching behavior, while preserving compatibility with the
existing API. This PR is a backwards compatible alternative to
precomputed fuzzy matcher introduces in another PR.

The new implementation is designed as a drop-in replacement. Any
behavioral differences are intentional and primarily related to improved
diacritic handling, scoring consistency, and correctness of highlight
positions.

Changes:
- Keeps the existing public API intact and preserves behavior in nearly
all cases.
- Enables diacritics-insensitive matching by default, improving results
across accented and non-English languages.
- Significantly improves performance, with measured speedups in the
range of ~5–20 times, depending on scenario and input size.
- Reduces heap allocations to near zero by using stack allocation and
pooled buffers instead of large per-match DP arrays.
- Simplifies and optimizes matching logic:
  - Folds the haystack only once per match.
  - Uses rolling DP buffers instead of `O(query × target)` tables.
- Replaces large match tables with a compact bitset when tracking
highlight positions.
- Improves consistency and correctness:
  - Normalizes path separators (`\` → `/`) during folding.
- Avoids returning highlight positions for PinYin-only matches where no
1:1 mapping exists.
- Introduces unit tests, including comparison tests against the legacy
implementation to validate compatibility.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #44066
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-28 21:23:12 -06:00
Jiří Polášek
64cabc8789 CmdPal: Fix window centering when moving to a display with different DPI (#45057)
## Summary of the Pull Request

This PR fixes centering of main window, when the window also moves
between move display with a different DPI.

- The centered position was calculated using the current window width
and height, but those values change after the window is moved to
accommodate the new display’s DPI.
- Calculations have been refactored out of main window to a helper
class.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #44932
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-28 21:21:11 -06:00
Jiří Polášek
989e005500 CmdPal: Run shutdown and restart commands in a hidden window (#45062)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #40621 
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-28 21:20:52 -06:00
Jiří Polášek
5f124cec55 CmdPal: Cache and show information for disabled command providers (#44278)
## Summary of the Pull Request

This PR adds a cache of command provider information so we can show
providers even when the command provider isn’t loaded.

It also updates the description for disabled extensions on the
Extensions page to always include the extension name.

Finally, it adds a placeholder icon for cases where an extension icon
isn’t loaded. Note that this doesn’t address fully transparent icons
that some extensions may inherit from the default template.

Before:

<img width="1883" height="167" alt="image"
src="https://github.com/user-attachments/assets/7ccaa669-9516-4b57-9646-4e755d29d75c"
/>


After:

<img width="1873" height="190" alt="image"
src="https://github.com/user-attachments/assets/f29549c2-ddd5-4688-ba9c-d1abd4b523a0"
/>


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-28 21:19:25 -06:00
Jiří Polášek
8ec530c65e CmdPal: GEH per partes; part 1: error report builder, sanitizer and internals tools setting page (#44140)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

This PR adds three parts of the original big bad global error handler
(error report builder, sanitization and internal tools UI).

### Error Report Generation

- `ErrorReportBuilder`: Produces a detailed, technical report with
system context.
- Comprehensive data: OS version, architecture, culture, app version,
elevation status, etc.
- Exception analysis: Coalesces nested exception messages and HRESULT
details for clearer diagnostics.

<details><summary>Example</summary>
<pre>

This is an error report generated by Windows Command Palette.
If you are seeing this, it means something went a little sideways in the
app.
You can help us fix it by filing a report at
https://aka.ms/powerToysReportBug.

(While you’re at it, give the details below a quick skim — just to make
sure there’s nothing personal you’d prefer not to share. It’s rare, but
sometimes little surprises sneak in.)
============================================================
Summary:
  Message:               Test exception; thrown from the UI thread
  Type:                  System.NotImplementedException
  Source:                Microsoft.CmdPal.UI
  Time:                  2025-08-25 18:54:44.3854569
  HRESULT:               0x80004001 (-2147467263)
  Context:               MainThreadException

Application:
  App version:           0.0.1.0
  Is elevated:           no

Environment:
  OS version:            Microsoft Windows 10.0.26120
  OS architecture:       X64
  Runtime identifier:    win-x64
  Framework:             .NET 9.0.8
  Process architecture:  X64
  Culture:               cs-CZ
  UI culture:            en-US

Stack Trace:
at
Microsoft.CmdPal.UI.Settings.InternalPage.ThrowPlainMainThreadException_Click(Object
sender, RoutedEventArgs e)
at
WinRT._EventSource_global__Microsoft_UI_Xaml_RoutedEventHandler.EventState.<GetEventInvoke>b__1_0(Object
sender, RoutedEventArgs e)
at ABI.Microsoft.UI.Xaml.RoutedEventHandler.Do_Abi_Invoke(IntPtr
thisPtr, IntPtr sender, IntPtr e)

------------------ Full Exception Details ------------------
System.NotImplementedException: Test exception; thrown from the UI
thread
at
Microsoft.CmdPal.UI.Settings.InternalPage.ThrowPlainMainThreadException_Click(Object
sender, RoutedEventArgs e)
at
WinRT._EventSource_global__Microsoft_UI_Xaml_RoutedEventHandler.EventState.<GetEventInvoke>b__1_0(Object
sender, RoutedEventArgs e)
at ABI.Microsoft.UI.Xaml.RoutedEventHandler.Do_Abi_Invoke(IntPtr
thisPtr, IntPtr sender, IntPtr e)

============================================================

</pre>
</details> 

Real-world example: #41362

### PII Sanitization Framework

- `ErrorReportSanitizer`: Multi-layer sanitization pipeline for
sensitive data.
- Nine specialized rule providers:
- `PiiRuleProvider`: Personally identifiable information (emails, phone
numbers, SSNs).
- `ProfilePathAndUsernameRuleProvider`: Windows user profiles and
usernames.
- `NetworkRuleProvider`: IP addresses, MAC addresses, network
identifiers.
- `SecretKeyValueRulesProvider`: API keys, tokens, passwords in
key/value formats.
  - `FilenameMaskRuleProvider`: Sensitive file paths and extensions.
  - `UrlRuleProvider`: URLs and web addresses.
  - `TokenRuleProvider`: JWT and other auth tokens.
  - `ConnectionStringRuleProvider`: Database connection strings.
- `EnvironmentPropertiesRuleProvider`: Environment variables and system
properties.

### Internals Tools Page

A page in settings available in non-CI-builds:

<img width="1305" height="745" alt="image"
src="https://github.com/user-attachments/assets/3145ecfd-997f-491d-8c8a-6096634b6045"
/>


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-28 21:09:37 -06:00
161 changed files with 12311 additions and 978 deletions

View File

@@ -565,7 +565,7 @@ perl(?:\s+-[a-zA-Z]\w*)+
regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\)
# regex choice
\(\?:[^)]+\|[^)]+\)
# \(\?:[^)]+\|[^)]+\)
# proto
^\s*(\w+)\s\g{-1} =

View File

@@ -104,8 +104,12 @@
^src/common/ManagedCommon/ColorFormatHelper\.cs$
^src/common/notifications/BackgroundActivatorDLL/cpp\.hint$
^src/common/sysinternals/Eula/
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$
^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/.*\.TestData\.cs$
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$

View File

@@ -597,6 +597,7 @@ frm
FROMTOUCH
fsanitize
fsmgmt
ftps
fuzzingtesting
fxf
FZE
@@ -646,6 +647,8 @@ GSM
gtm
guiddata
GUITHREADINFO
Gotcha
Gotchas
GValue
gwl
GWLP
@@ -1329,7 +1332,7 @@ phwnd
pici
pidl
PIDLIST
PII
pii
pinfo
pinvoke
pipename
@@ -1531,6 +1534,7 @@ riid
RKey
RNumber
rollups
ROOTOWNER
rop
ROUNDSMALL
ROWSETEXT
@@ -1715,6 +1719,7 @@ srw
srwlock
sse
ssf
Ssn
sszzz
STACKFRAME
stackoverflow
@@ -1824,6 +1829,7 @@ TEXTBOXNEWLINE
textextractor
TEXTINCLUDE
tfopen
tgamma
tgz
THEMECHANGED
themeresources

21
.github/skills/issue-fix/LICENSE.txt vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

130
.github/skills/issue-fix/SKILL.md vendored Normal file
View File

@@ -0,0 +1,130 @@
---
name: issue-fix
description: Automatically fix GitHub issues using AI-assisted code generation. Use when asked to fix an issue, implement a feature from an issue, auto-fix an issue, apply implementation plan, create code changes for an issue, or resolve a GitHub issue. Creates isolated git worktree and applies AI-generated fixes based on the implementation plan.
license: Complete terms in LICENSE.txt
---
# Issue Fix Skill
Automatically fix GitHub issues by creating isolated worktrees and applying AI-generated code changes based on implementation plans.
## Skill Contents
This skill is **self-contained** with all required resources:
```
.github/skills/issue-fix/
├── SKILL.md # This file
├── LICENSE.txt # MIT License
├── scripts/
│ └── Start-IssueAutoFix.ps1 # Main fix script
└── references/
└── fix-issue.prompt.md # Full AI prompt template
```
## Output Directory
Worktrees are created at the drive root level:
```
Q:/PowerToys-xxxx/ # Worktree for issue (xxxx = short hash)
├── Generated Files/
│ └── issueReview/
│ └── <issue-number>/ # Copied from main repo
│ ├── overview.md
│ └── implementation-plan.md
└── <normal repo structure>
```
## When to Use This Skill
- Fix a specific GitHub issue automatically
- Implement a feature described in an issue
- Apply an existing implementation plan
- Create code changes for an issue
- Auto-fix high-confidence issues
- Resolve issues that have been reviewed
## Prerequisites
- GitHub CLI (`gh`) installed and authenticated
- Issue must be reviewed first (use `issue-review` skill)
- PowerShell 7+ for running scripts
- Copilot CLI or Claude CLI installed
## Required Variables
⚠️ **Before starting**, confirm `{{IssueNumber}}` with the user. If not provided, **ASK**: "What issue number should I fix?"
| Variable | Description | Example |
|----------|-------------|---------|
| `{{IssueNumber}}` | GitHub issue number to fix | `44044` |
## Workflow
### Step 1: Ensure Issue is Reviewed
If not already reviewed, use the `issue-review` skill first.
### Step 2: Run Auto-Fix
Execute the fix script (use paths relative to this skill folder):
```powershell
# From repo root
.github/skills/issue-fix/scripts/Start-IssueAutoFix.ps1 -IssueNumber {{IssueNumber}} -CLIType copilot
```
This will:
1. Create a new git worktree with branch `issue/{{IssueNumber}}`
2. Copy the review files to the worktree
3. Launch Copilot CLI to implement the fix
4. Build and verify the changes
### Step 3: Verify Changes
Navigate to the worktree and review:
```powershell
# List worktrees
git worktree list
# Check changes in the worktree
cd Q:/PowerToys-xxxx
git diff
git status
```
## CLI Options
| Parameter | Description | Default |
|-----------|-------------|---------|
| `-IssueNumber` | Issue to fix | Required |
| `-CLIType` | AI CLI to use: `copilot` or `claude` | `copilot` |
| `-Force` | Skip confirmation prompts | `false` |
## Batch Fix
To fix multiple issues:
```powershell
.github/skills/issue-fix/scripts/Start-IssueAutoFix.ps1 -IssueNumbers 44044, 32950 -CLIType copilot -Force
```
## After Fixing
Once the fix is complete, use the `submit-pr` skill to create a PR.
## AI Prompt Reference
For manual AI invocation, the full prompt is at:
- `references/fix-issue.prompt.md` (relative to this skill folder)
## Troubleshooting
| Problem | Solution |
|---------|----------|
| Worktree already exists | Use existing worktree or delete with `git worktree remove <path>` |
| No implementation plan | Use `issue-review` skill first |
| Build failures | Check build logs, may need manual intervention |
| CLI not found | Install Copilot CLI |

View File

@@ -0,0 +1,72 @@
---
agent: 'agent'
description: 'Execute the fix for a GitHub issue using the previously generated implementation plan'
---
# Fix GitHub Issue
## Dependencies
Source review prompt (for generating the implementation plan if missing):
- .github/prompts/review-issue.prompt.md
Required plan file (single source of truth):
- Generated Files/issueReview/{{issue_number}}/implementation-plan.md
## Dependency Handling
1) If `implementation-plan.md` exists → proceed.
2) If missing → run the review prompt:
- Invoke: `.github/prompts/review-issue.prompt.md`
- Pass: `issue_number={{issue_number}}`
- Then re-check for `implementation-plan.md`.
3) If still missing → stop and generate:
- `Generated Files/issueFix/{{issue_number}}/manual-steps.md` containing:
“implementation-plan.md not found; please run .github/prompts/review-issue.prompt.md for #{{issue_number}}.”
# GOAL
For **#{{issue_number}}**:
- Use implementation-plan.md as the single authority.
- Apply code and test changes directly in the repository.
- Produce a PR-ready description.
# OUTPUT FILES
1) Generated Files/issueFix/{{issue_number}}/pr-description.md
2) Generated Files/issueFix/{{issue_number}}/manual-steps.md # only if human interaction or external setup is required
# EXECUTION RULES
1) Read implementation-plan.md and execute:
- Layers & Files → edit/create as listed
- Pattern Choices → follow repository conventions
- Fundamentals (perf, security, compatibility, accessibility)
- Logging & Exceptions
- Telemetry (only if explicitly included in the plan)
- Risks & Mitigations
- Tests to Add
2) Locate affected files via `rg` or `git grep`.
3) Add/update tests to enforce the fixed behavior.
4) If any ambiguity exists, add:
// TODO(Human input needed): <clarification needed>
5) Verify locally: build & tests run successfully.
# pr-description.md should include:
- Title: `Fix: <short summary> (#{{issue_number}})`
- What changed and why the fix works
- Files or modules touched
- Risks & mitigations (implemented)
- Tests added/updated and how to run them
- Telemetry behavior (if applicable)
- Validation / reproduction steps
- `Closes #{{issue_number}}`
# manual-steps.md (only if needed)
- List required human actions: secrets, config, approvals, missing info, or code comments requiring human decisions.
# IMPORTANT
- Apply code and tests directly; do not produce patch files.
- Follow implementation-plan.md as the source of truth.
- Insert comments for human review where a decision or input is required.
- Use repository conventions and deterministic, minimal changes.
# FINALIZE
- Write pr-description.md
- Write manual-steps.md only if needed
- Print concise success message or note items requiring human interaction

View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"github-artifacts": {
"command": "cmd",
"args": ["/c", "for /f %i in ('git rev-parse --show-toplevel') do node %i/tools/mcp/github-artifacts/launch.js"],
"tools": ["*"]
}
}
}

View File

@@ -0,0 +1,643 @@
# IssueReviewLib.ps1 - Helpers for issue auto-fix workflow
# Part of the PowerToys GitHub Copilot/Claude Code issue review system
# This is a trimmed version with only what issue-fix needs
#region Console Output Helpers
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Success { param([string]$Message) Write-Host $Message -ForegroundColor Green }
#endregion
#region Repository Helpers
function Get-RepoRoot {
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { throw 'Not inside a git repository.' }
return (Resolve-Path $root).Path
}
function Get-GeneratedFilesPath {
param([string]$RepoRoot)
return Join-Path $RepoRoot 'Generated Files'
}
function Get-IssueReviewPath {
param(
[string]$RepoRoot,
[int]$IssueNumber
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
return Join-Path $genFiles "issueReview/$IssueNumber"
}
function Ensure-DirectoryExists {
param([string]$Path)
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
#endregion
#region CLI Detection
function Get-AvailableCLI {
<#
.SYNOPSIS
Detect which AI CLI is available: GitHub Copilot CLI or Claude Code.
#>
# Check for standalone GitHub Copilot CLI
$copilotCLI = Get-Command 'copilot' -ErrorAction SilentlyContinue
if ($copilotCLI) {
return @{ Name = 'GitHub Copilot CLI'; Command = 'copilot'; Type = 'copilot' }
}
# Check for Claude Code CLI
$claudeCode = Get-Command 'claude' -ErrorAction SilentlyContinue
if ($claudeCode) {
return @{ Name = 'Claude Code CLI'; Command = 'claude'; Type = 'claude' }
}
# Check for GitHub Copilot CLI via gh extension
$ghCopilot = Get-Command 'gh' -ErrorAction SilentlyContinue
if ($ghCopilot) {
$copilotCheck = gh extension list 2>&1 | Select-String -Pattern 'copilot'
if ($copilotCheck) {
return @{ Name = 'GitHub Copilot CLI (gh extension)'; Command = 'gh'; Type = 'gh-copilot' }
}
}
# Check for VS Code CLI
$code = Get-Command 'code' -ErrorAction SilentlyContinue
if ($code) {
return @{ Name = 'VS Code (Copilot Chat)'; Command = 'code'; Type = 'vscode' }
}
return $null
}
#endregion
#region Issue Review Results Helpers
function Get-IssueReviewResult {
<#
.SYNOPSIS
Check if an issue has been reviewed and get its results.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$RepoRoot
)
$reviewPath = Get-IssueReviewPath -RepoRoot $RepoRoot -IssueNumber $IssueNumber
$result = @{
IssueNumber = $IssueNumber
Path = $reviewPath
HasOverview = $false
HasImplementationPlan = $false
OverviewPath = $null
ImplementationPlanPath = $null
}
$overviewPath = Join-Path $reviewPath 'overview.md'
$implPlanPath = Join-Path $reviewPath 'implementation-plan.md'
if (Test-Path $overviewPath) {
$result.HasOverview = $true
$result.OverviewPath = $overviewPath
}
if (Test-Path $implPlanPath) {
$result.HasImplementationPlan = $true
$result.ImplementationPlanPath = $implPlanPath
}
return $result
}
function Get-HighConfidenceIssues {
<#
.SYNOPSIS
Find issues with high confidence for auto-fix based on review results.
#>
param(
[Parameter(Mandatory)]
[string]$RepoRoot,
[int]$MinFeasibilityScore = 70,
[int]$MinClarityScore = 60,
[int]$MaxEffortDays = 2,
[int[]]$FilterIssueNumbers = @()
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
$reviewDir = Join-Path $genFiles 'issueReview'
if (-not (Test-Path $reviewDir)) {
return @()
}
$highConfidence = @()
Get-ChildItem -Path $reviewDir -Directory | ForEach-Object {
$issueNum = [int]$_.Name
if ($FilterIssueNumbers.Count -gt 0 -and $issueNum -notin $FilterIssueNumbers) {
return
}
$overviewPath = Join-Path $_.FullName 'overview.md'
$implPlanPath = Join-Path $_.FullName 'implementation-plan.md'
if (-not (Test-Path $overviewPath) -or -not (Test-Path $implPlanPath)) {
return
}
$overview = Get-Content $overviewPath -Raw
$feasibility = 0
$clarity = 0
$effortDays = 999
if ($overview -match 'Technical Feasibility[^\d]*(\d+)/100') {
$feasibility = [int]$Matches[1]
}
if ($overview -match 'Requirement Clarity[^\d]*(\d+)/100') {
$clarity = [int]$Matches[1]
}
if ($overview -match 'Effort Estimate[^|]*\|\s*[\d.]+(?:-(\d+))?\s*days?') {
if ($Matches[1]) {
$effortDays = [int]$Matches[1]
} elseif ($overview -match 'Effort Estimate[^|]*\|\s*(\d+)\s*days?') {
$effortDays = [int]$Matches[1]
}
}
if ($overview -match 'Effort Estimate[^|]*\|[^|]*\|\s*(XS|S)\b') {
if ($Matches[1] -eq 'XS') { $effortDays = 1 } else { $effortDays = 2 }
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(XS\)') {
$effortDays = 1
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(S\)') {
$effortDays = 2
}
if ($feasibility -ge $MinFeasibilityScore -and
$clarity -ge $MinClarityScore -and
$effortDays -le $MaxEffortDays) {
$highConfidence += @{
IssueNumber = $issueNum
FeasibilityScore = $feasibility
ClarityScore = $clarity
EffortDays = $effortDays
OverviewPath = $overviewPath
ImplementationPlanPath = $implPlanPath
}
}
}
return $highConfidence | Sort-Object -Property FeasibilityScore -Descending
}
#endregion
#region Release & PR Status Helpers
function Get-PRReleaseStatus {
<#
.SYNOPSIS
Check if a PR has been merged and released.
.DESCRIPTION
Queries GitHub to determine:
1. If the PR is merged
2. What release (if any) contains the merge commit
.OUTPUTS
@{
PRNumber = <int>
IsMerged = $true | $false
MergeCommit = <commit sha or $null>
ReleasedIn = <version string or $null> # e.g., "v0.90.0"
IsReleased = $true | $false
}
#>
param(
[Parameter(Mandatory)]
[int]$PRNumber,
[string]$Repo = 'microsoft/PowerToys'
)
$result = @{
PRNumber = $PRNumber
IsMerged = $false
MergeCommit = $null
ReleasedIn = $null
IsReleased = $false
}
try {
# Get PR details from GitHub
$prJson = gh pr view $PRNumber --repo $Repo --json state,mergeCommit,mergedAt 2>$null
if (-not $prJson) {
return $result
}
$pr = $prJson | ConvertFrom-Json
if ($pr.state -eq 'MERGED' -and $pr.mergeCommit) {
$result.IsMerged = $true
$result.MergeCommit = $pr.mergeCommit.oid
# Check which release tags contain this commit
# Use git tag --contains to find tags that include the merge commit
$tags = git tag --contains $result.MergeCommit 2>$null
if ($tags) {
# Filter to release tags (v0.XX.X pattern) and get the earliest one
$releaseTags = $tags | Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } | Sort-Object
if ($releaseTags) {
$result.ReleasedIn = $releaseTags | Select-Object -First 1
$result.IsReleased = $true
}
}
}
}
catch {
# Silently fail - will return default "not merged" status
}
return $result
}
function Get-LatestRelease {
<#
.SYNOPSIS
Get the latest release version of PowerToys.
#>
param(
[string]$Repo = 'microsoft/PowerToys'
)
try {
$releaseJson = gh release view --repo $Repo --json tagName 2>$null
if ($releaseJson) {
$release = $releaseJson | ConvertFrom-Json
return $release.tagName
}
}
catch {
# Fallback: try to get from git tags
$latestTag = git describe --tags --abbrev=0 2>$null
if ($latestTag) {
return $latestTag
}
}
return $null
}
#endregion
#region Implementation Plan Analysis
function Get-ImplementationPlanStatus {
<#
.SYNOPSIS
Parse implementation-plan.md to determine the recommended action.
.DESCRIPTION
Reads the implementation plan and extracts the status/recommendation.
For "already resolved" issues, also checks if the fix has been released.
Returns an object indicating what action should be taken.
.OUTPUTS
@{
Status = 'AlreadyResolved' | 'FixedButUnreleased' | 'NeedsClarification' | 'Duplicate' | 'WontFix' | 'ReadyToImplement' | 'Unknown'
Action = 'CloseIssue' | 'AddComment' | 'LinkDuplicate' | 'ImplementFix' | 'Skip'
Reason = <string explaining why>
RelatedPR = <PR number if already fixed>
ReleasedIn = <version if released, e.g., "v0.90.0">
DuplicateOf = <issue number if duplicate>
CommentText = <suggested comment if applicable>
}
#>
param(
[Parameter(Mandatory)]
[string]$ImplementationPlanPath,
[switch]$SkipReleaseCheck
)
$result = @{
Status = 'Unknown'
Action = 'Skip'
Reason = 'Could not determine status from implementation plan'
RelatedPR = $null
ReleasedIn = $null
DuplicateOf = $null
CommentText = $null
}
if (-not (Test-Path $ImplementationPlanPath)) {
$result.Reason = 'Implementation plan file not found'
return $result
}
$content = Get-Content $ImplementationPlanPath -Raw
# Check for ALREADY RESOLVED status
if ($content -match '(?i)STATUS:\s*ALREADY\s+RESOLVED' -or
$content -match '(?i)⚠️\s*STATUS:\s*ALREADY\s+RESOLVED' -or
$content -match '(?i)This issue has been fixed by' -or
$content -match '(?i)No implementation work is needed') {
# Try to extract the PR number
$prNumber = $null
if ($content -match '\[PR #(\d+)\]' -or $content -match 'PR #(\d+)' -or $content -match '/pull/(\d+)') {
$prNumber = [int]$Matches[1]
$result.RelatedPR = $prNumber
}
# Check if the fix has been released
if ($prNumber -and -not $SkipReleaseCheck) {
$prStatus = Get-PRReleaseStatus -PRNumber $prNumber
if ($prStatus.IsReleased) {
# Fix is released - safe to close
$result.Status = 'AlreadyResolved'
$result.Action = 'CloseIssue'
$result.ReleasedIn = $prStatus.ReleasedIn
$result.Reason = "Issue fixed by PR #$prNumber, released in $($prStatus.ReleasedIn)"
$result.CommentText = @"
This issue has been fixed by PR #$prNumber and is available in **$($prStatus.ReleasedIn)**.
Please update to the latest version. If you're still experiencing this issue after updating, please reopen with additional details.
"@
}
elseif ($prStatus.IsMerged) {
# PR merged but not yet released - add comment but don't close
$result.Status = 'FixedButUnreleased'
$result.Action = 'AddComment'
$result.Reason = "Issue fixed by PR #$prNumber, but not yet released"
$result.CommentText = @"
This issue has been fixed by PR #$prNumber, which has been merged but **not yet released**.
The fix will be available in the next PowerToys release. You can:
- Wait for the next official release
- Build from source to get the fix immediately
We'll close this issue once the fix is released.
"@
}
else {
# PR exists but not merged - treat as ready to implement (PR might have been reverted)
$result.Status = 'ReadyToImplement'
$result.Action = 'ImplementFix'
$result.Reason = "PR #$prNumber exists but is not merged - may need reimplementation"
}
}
elseif ($prNumber) {
# Skip release check requested or no PR number - assume it's resolved
$result.Status = 'AlreadyResolved'
$result.Action = 'CloseIssue'
$result.Reason = 'Issue has already been fixed'
$result.CommentText = "This issue has been fixed by PR #$prNumber. Closing as resolved."
}
else {
# No PR number found - just mark as resolved with generic message
$result.Status = 'AlreadyResolved'
$result.Action = 'CloseIssue'
$result.Reason = 'Issue appears to have been resolved'
$result.CommentText = "Based on analysis, this issue appears to have already been resolved. Please verify and reopen if the issue persists."
}
return $result
}
# Check for DUPLICATE status
if ($content -match '(?i)STATUS:\s*DUPLICATE' -or
$content -match '(?i)This is a duplicate of' -or
$content -match '(?i)duplicate of #(\d+)') {
$result.Status = 'Duplicate'
$result.Action = 'LinkDuplicate'
$result.Reason = 'Issue is a duplicate'
# Try to extract the duplicate issue number
if ($content -match 'duplicate of #(\d+)' -or $content -match '#(\d+)') {
$result.DuplicateOf = [int]$Matches[1]
$result.CommentText = "This appears to be a duplicate of #$($result.DuplicateOf)."
}
return $result
}
# Check for NEEDS CLARIFICATION status
if ($content -match '(?i)STATUS:\s*NEEDS?\s+CLARIFICATION' -or
$content -match '(?i)STATUS:\s*NEEDS?\s+MORE\s+INFO' -or
$content -match '(?i)cannot proceed without' -or
$content -match '(?i)need(?:s)? more information') {
$result.Status = 'NeedsClarification'
$result.Action = 'AddComment'
$result.Reason = 'Issue needs more information from reporter'
# Try to extract what information is needed
if ($content -match '(?i)(?:need(?:s)?|require(?:s)?|missing)[:\s]+([^\n]+)') {
$result.CommentText = "Additional information is needed to proceed with this issue: $($Matches[1].Trim())"
} else {
$result.CommentText = "Could you please provide more details about this issue? Specifically, steps to reproduce and expected vs actual behavior would help."
}
return $result
}
# Check for WONT FIX / NOT FEASIBLE status
if ($content -match '(?i)STATUS:\s*(?:WONT?\s+FIX|NOT\s+FEASIBLE|REJECTED)' -or
$content -match '(?i)(?:not|cannot be) (?:feasible|implemented)' -or
$content -match '(?i)recommend(?:ed)?\s+(?:to\s+)?close') {
$result.Status = 'WontFix'
$result.Action = 'AddComment'
$result.Reason = 'Issue is not feasible or recommended to close'
# Try to extract the reason
if ($content -match '(?i)(?:because|reason|due to)[:\s]+([^\n]+)') {
$result.CommentText = "After analysis, this issue cannot be implemented: $($Matches[1].Trim())"
}
return $result
}
# Check for external dependency / blocked status
if ($content -match '(?i)STATUS:\s*BLOCKED' -or
$content -match '(?i)blocked by' -or
$content -match '(?i)depends on external' -or
$content -match '(?i)waiting for upstream') {
$result.Status = 'Blocked'
$result.Action = 'AddComment'
$result.Reason = 'Issue is blocked by external dependency'
return $result
}
# Check for READY TO IMPLEMENT (positive signals)
if ($content -match '(?i)## \d+\)\s*Task Breakdown' -or
$content -match '(?i)implementation steps' -or
$content -match '(?i)## Layers & Files' -or
($content -match '(?i)Feasibility' -and $content -notmatch '(?i)not\s+feasible')) {
$result.Status = 'ReadyToImplement'
$result.Action = 'ImplementFix'
$result.Reason = 'Implementation plan is ready'
return $result
}
# Default: if we have a detailed plan, assume it's ready
if ($content.Length -gt 500 -and $content -match '(?i)##') {
$result.Status = 'ReadyToImplement'
$result.Action = 'ImplementFix'
$result.Reason = 'Implementation plan appears complete'
}
return $result
}
function Invoke-ImplementationPlanAction {
<#
.SYNOPSIS
Execute the recommended action from the implementation plan analysis.
.DESCRIPTION
Based on the status from Get-ImplementationPlanStatus, takes appropriate action:
- CloseIssue: Closes the issue with a comment
- AddComment: Adds a comment to the issue
- LinkDuplicate: Marks as duplicate
- ImplementFix: Returns $true to indicate code fix should proceed
- Skip: Returns $false
.OUTPUTS
@{
ActionTaken = <string describing what was done>
ShouldProceedWithFix = $true | $false
Success = $true | $false
}
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[hashtable]$PlanStatus,
[switch]$DryRun
)
$result = @{
ActionTaken = 'None'
ShouldProceedWithFix = $false
Success = $true
}
switch ($PlanStatus.Action) {
'ImplementFix' {
$result.ActionTaken = 'Proceeding with code fix'
$result.ShouldProceedWithFix = $true
Info "[Issue #$IssueNumber] Status: $($PlanStatus.Status) - $($PlanStatus.Reason)"
}
'CloseIssue' {
$result.ActionTaken = "Closing issue: $($PlanStatus.Reason)"
Info "[Issue #$IssueNumber] $($PlanStatus.Status): $($PlanStatus.Reason)"
if (-not $DryRun) {
$comment = $PlanStatus.CommentText
if (-not $comment) {
$comment = "Closing based on automated analysis: $($PlanStatus.Reason)"
}
try {
# Add comment explaining closure
gh issue comment $IssueNumber --body $comment 2>&1 | Out-Null
# Close the issue
if ($PlanStatus.RelatedPR) {
gh issue close $IssueNumber --reason "completed" --comment "Resolved by PR #$($PlanStatus.RelatedPR)" 2>&1 | Out-Null
} else {
gh issue close $IssueNumber --reason "completed" 2>&1 | Out-Null
}
Success "[Issue #$IssueNumber] ✓ Closed with comment"
}
catch {
Err "[Issue #$IssueNumber] Failed to close: $($_.Exception.Message)"
$result.Success = $false
}
} else {
Info "[Issue #$IssueNumber] (DryRun) Would close with: $($PlanStatus.CommentText)"
}
}
'AddComment' {
$result.ActionTaken = "Adding comment: $($PlanStatus.Reason)"
Info "[Issue #$IssueNumber] $($PlanStatus.Status): $($PlanStatus.Reason)"
if (-not $DryRun -and $PlanStatus.CommentText) {
try {
gh issue comment $IssueNumber --body $PlanStatus.CommentText 2>&1 | Out-Null
Success "[Issue #$IssueNumber] ✓ Comment added"
}
catch {
Err "[Issue #$IssueNumber] Failed to add comment: $($_.Exception.Message)"
$result.Success = $false
}
} else {
Info "[Issue #$IssueNumber] (DryRun) Would comment: $($PlanStatus.CommentText)"
}
}
'LinkDuplicate' {
$result.ActionTaken = "Marking as duplicate of #$($PlanStatus.DuplicateOf)"
Info "[Issue #$IssueNumber] Duplicate of #$($PlanStatus.DuplicateOf)"
if (-not $DryRun -and $PlanStatus.DuplicateOf) {
try {
gh issue close $IssueNumber --reason "not_planned" --comment "Closing as duplicate of #$($PlanStatus.DuplicateOf)" 2>&1 | Out-Null
Success "[Issue #$IssueNumber] ✓ Closed as duplicate"
}
catch {
Err "[Issue #$IssueNumber] Failed to close as duplicate: $($_.Exception.Message)"
$result.Success = $false
}
}
}
'Skip' {
$result.ActionTaken = "Skipped: $($PlanStatus.Reason)"
Warn "[Issue #$IssueNumber] Skipping: $($PlanStatus.Reason)"
}
}
return $result
}
#endregion
#region Worktree Integration
function Copy-IssueReviewToWorktree {
<#
.SYNOPSIS
Copy the Generated Files for an issue to a worktree.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$SourceRepoRoot,
[Parameter(Mandatory)]
[string]$WorktreePath
)
$sourceReviewPath = Get-IssueReviewPath -RepoRoot $SourceRepoRoot -IssueNumber $IssueNumber
$destReviewPath = Get-IssueReviewPath -RepoRoot $WorktreePath -IssueNumber $IssueNumber
if (-not (Test-Path $sourceReviewPath)) {
throw "Issue review files not found at: $sourceReviewPath"
}
Ensure-DirectoryExists -Path $destReviewPath
Copy-Item -Path "$sourceReviewPath\*" -Destination $destReviewPath -Recurse -Force
Info "Copied issue review files to: $destReviewPath"
return $destReviewPath
}
#endregion

View File

@@ -0,0 +1,530 @@
<#!
.SYNOPSIS
Auto-fix high-confidence issues using worktrees and AI CLI.
.DESCRIPTION
Finds issues with high confidence scores from the review results, creates worktrees
for each, copies the Generated Files, and kicks off the FixIssue agent to implement fixes.
.PARAMETER IssueNumber
Specific issue number to fix. If not specified, finds high-confidence issues automatically.
.PARAMETER MinFeasibilityScore
Minimum Technical Feasibility score (0-100). Default: 70.
.PARAMETER MinClarityScore
Minimum Requirement Clarity score (0-100). Default: 60.
.PARAMETER MaxEffortDays
Maximum effort estimate in days. Default: 2 (Small fixes).
.PARAMETER MaxParallel
Maximum parallel fix jobs. Default: 5 (worktrees are resource-intensive).
.PARAMETER CLIType
AI CLI to use: claude, gh-copilot, or vscode. Auto-detected if not specified.
.PARAMETER DryRun
List issues without starting fixes.
.PARAMETER SkipWorktree
Fix in the current repository instead of creating worktrees (useful for single issue).
.PARAMETER VSCodeProfile
VS Code profile to use when opening worktrees. Default: Default.
.PARAMETER AutoCommit
Automatically commit changes after successful fix.
.PARAMETER CreatePR
Automatically create a pull request after successful fix.
.EXAMPLE
# Fix a specific issue
./Start-IssueAutoFix.ps1 -IssueNumber 12345
.EXAMPLE
# Find and fix all high-confidence issues (dry run)
./Start-IssueAutoFix.ps1 -DryRun
.EXAMPLE
# Fix issues with very high confidence
./Start-IssueAutoFix.ps1 -MinFeasibilityScore 80 -MinClarityScore 70 -MaxEffortDays 1
.EXAMPLE
# Fix single issue in current repo (no worktree)
./Start-IssueAutoFix.ps1 -IssueNumber 12345 -SkipWorktree
.NOTES
Prerequisites:
- Run Start-BulkIssueReview.ps1 first to generate review files
- GitHub CLI (gh) authenticated
- Claude Code CLI or VS Code with Copilot
Results:
- Worktrees created at ../<RepoName>-<hash>/
- Generated Files copied to each worktree
- Fix agent invoked in each worktree
#>
[CmdletBinding()]
param(
[int]$IssueNumber,
[int]$MinFeasibilityScore = 70,
[int]$MinClarityScore = 60,
[int]$MaxEffortDays = 2,
[int]$MaxParallel = 5,
[ValidateSet('claude', 'copilot', 'gh-copilot', 'vscode', 'auto')]
[string]$CLIType = 'auto',
[switch]$DryRun,
[switch]$SkipWorktree,
[Alias('Profile')]
[string]$VSCodeProfile = 'Default',
[switch]$AutoCommit,
[switch]$CreatePR,
[switch]$Force,
[switch]$Help
)
# Load libraries
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir/IssueReviewLib.ps1"
# Load worktree library from tools/build
$repoRoot = Get-RepoRoot
$worktreeLib = Join-Path $repoRoot 'tools/build/WorktreeLib.ps1'
if (Test-Path $worktreeLib) {
. $worktreeLib
}
# Show help
if ($Help) {
Get-Help $MyInvocation.MyCommand.Path -Full
return
}
function Start-IssueFixInWorktree {
<#
.SYNOPSIS
Analyze implementation plan and either take action or create worktree for fix.
.DESCRIPTION
First analyzes the implementation plan to determine if:
- Issue is already resolved (close it)
- Issue needs clarification (add comment)
- Issue is a duplicate (close as duplicate)
- Issue is ready to implement (create worktree and fix)
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$SourceRepoRoot,
[string]$CLIType = 'claude',
[string]$VSCodeProfile = 'Default',
[switch]$SkipWorktree,
[switch]$DryRun
)
$issueReviewPath = Get-IssueReviewPath -RepoRoot $SourceRepoRoot -IssueNumber $IssueNumber
$overviewPath = Join-Path $issueReviewPath 'overview.md'
$implPlanPath = Join-Path $issueReviewPath 'implementation-plan.md'
# Verify review files exist
if (-not (Test-Path $overviewPath)) {
throw "No overview.md found for issue #$IssueNumber. Run Start-BulkIssueReview.ps1 first."
}
if (-not (Test-Path $implPlanPath)) {
throw "No implementation-plan.md found for issue #$IssueNumber. Run Start-BulkIssueReview.ps1 first."
}
# =====================================
# STEP 1: Analyze the implementation plan
# =====================================
Info "Analyzing implementation plan for issue #$IssueNumber..."
$planStatus = Get-ImplementationPlanStatus -ImplementationPlanPath $implPlanPath
# =====================================
# STEP 2: Execute the recommended action
# =====================================
$actionResult = Invoke-ImplementationPlanAction -IssueNumber $IssueNumber -PlanStatus $planStatus -DryRun:$DryRun
# If we shouldn't proceed with fix, return early
if (-not $actionResult.ShouldProceedWithFix) {
return @{
IssueNumber = $IssueNumber
WorktreePath = $null
Success = $actionResult.Success
ActionTaken = $actionResult.ActionTaken
SkippedCodeFix = $true
}
}
# =====================================
# STEP 3: Proceed with code fix
# =====================================
$workingDir = $SourceRepoRoot
if (-not $SkipWorktree) {
# Use the simplified New-WorktreeFromIssue.cmd which only needs issue number
$worktreeCmd = Join-Path $SourceRepoRoot 'tools/build/New-WorktreeFromIssue.cmd'
Info "Creating worktree for issue #$IssueNumber..."
# Call the cmd script with issue number and -NoVSCode for automation
& cmd /c $worktreeCmd $IssueNumber -NoVSCode
if ($LASTEXITCODE -ne 0) {
throw "Failed to create worktree for issue #$IssueNumber"
}
# Find the created worktree
$entries = Get-WorktreeEntries
$worktreeEntry = $entries | Where-Object { $_.Branch -like "issue/$IssueNumber*" } | Select-Object -First 1
if (-not $worktreeEntry) {
throw "Failed to find worktree for issue #$IssueNumber"
}
$workingDir = $worktreeEntry.Path
Info "Worktree created at: $workingDir"
# Copy Generated Files to worktree
Info "Copying review files to worktree..."
$destReviewPath = Copy-IssueReviewToWorktree -IssueNumber $IssueNumber -SourceRepoRoot $SourceRepoRoot -WorktreePath $workingDir
Info "Review files copied to: $destReviewPath"
# Copy .github/skills folder to worktree (needed for MCP config)
$sourceSkillsPath = Join-Path $SourceRepoRoot '.github/skills'
$destSkillsPath = Join-Path $workingDir '.github/skills'
if (Test-Path $sourceSkillsPath) {
$destGithubPath = Join-Path $workingDir '.github'
if (-not (Test-Path $destGithubPath)) {
New-Item -ItemType Directory -Path $destGithubPath -Force | Out-Null
}
Copy-Item -Path $sourceSkillsPath -Destination $destGithubPath -Recurse -Force
Info "Copied .github/skills to worktree"
}
}
# Build the prompt for the fix agent
$prompt = @"
You are the FixIssue agent. Fix GitHub issue #$IssueNumber.
The implementation plan is at: Generated Files/issueReview/$IssueNumber/implementation-plan.md
The overview is at: Generated Files/issueReview/$IssueNumber/overview.md
Follow the implementation plan exactly. Build and verify after each change.
"@
# Start the fix agent
Info "Starting fix agent for issue #$IssueNumber in $workingDir..."
# MCP config for github-artifacts tools (relative to repo root)
$mcpConfig = '@.github/skills/issue-fix/references/mcp-config.json'
switch ($CLIType) {
'copilot' {
# GitHub Copilot CLI (standalone copilot command)
# -p: Non-interactive prompt mode (exits after completion)
# --yolo: Enable all permissions for automated execution
# -s: Silent mode - output only agent response
# --additional-mcp-config: Load github-artifacts MCP for image/attachment analysis
$copilotArgs = @(
'--additional-mcp-config', $mcpConfig,
'-p', $prompt,
'--yolo',
'-s'
)
Info "Running: copilot $($copilotArgs -join ' ')"
Push-Location $workingDir
try {
& copilot @copilotArgs
if ($LASTEXITCODE -ne 0) {
Warn "Copilot exited with code $LASTEXITCODE"
}
} finally {
Pop-Location
}
}
'claude' {
$claudeArgs = @(
'--print',
'--dangerously-skip-permissions',
'--prompt', $prompt
)
Start-Process -FilePath 'claude' -ArgumentList $claudeArgs -WorkingDirectory $workingDir -Wait -NoNewWindow
}
'gh-copilot' {
# Use GitHub Copilot CLI via gh extension
# gh copilot suggest requires interactive mode, so we open VS Code with the prompt
Info "GitHub Copilot CLI detected. Opening VS Code with prompt..."
# Create a prompt file in the worktree for easy access
$promptFile = Join-Path $workingDir "Generated Files/issueReview/$IssueNumber/fix-prompt.md"
$promptContent = @"
# Fix Issue #$IssueNumber
## Instructions
$prompt
## Quick Start
1. Read the implementation plan: ``Generated Files/issueReview/$IssueNumber/implementation-plan.md``
2. Read the overview: ``Generated Files/issueReview/$IssueNumber/overview.md``
3. Follow the plan step by step
4. Build and test after each change
"@
Set-Content -Path $promptFile -Value $promptContent -Force
# Open VS Code with the worktree
code --new-window $workingDir --profile $VSCodeProfile
Info "VS Code opened at $workingDir"
Info "Prompt file created at: $promptFile"
Info "Use GitHub Copilot in VS Code to implement the fix."
}
'vscode' {
# Open VS Code and let user manually trigger the fix
code --new-window $workingDir --profile $VSCodeProfile
Info "VS Code opened at $workingDir. Use Copilot to implement the fix."
}
default {
Warn "CLI type '$CLIType' not fully supported for auto-fix. Opening VS Code..."
code --new-window $workingDir --profile $VSCodeProfile
}
}
# Check if any changes were actually made
$hasChanges = $false
Push-Location $workingDir
try {
$uncommitted = git status --porcelain 2>$null
$commitsAhead = git rev-list main..HEAD --count 2>$null
if ($uncommitted -or ($commitsAhead -gt 0)) {
$hasChanges = $true
}
} finally {
Pop-Location
}
return @{
IssueNumber = $IssueNumber
WorktreePath = $workingDir
Success = $true
ActionTaken = 'CodeFixAttempted'
SkippedCodeFix = $false
HasChanges = $hasChanges
}
}
#region Main Script
try {
Info "Repository root: $repoRoot"
# Detect or validate CLI
if ($CLIType -eq 'auto') {
$cli = Get-AvailableCLI
if ($cli) {
$CLIType = $cli.Type
Info "Auto-detected CLI: $($cli.Name)"
} else {
$CLIType = 'vscode'
Info "No CLI detected, will use VS Code"
}
}
# Find issues to fix
$issuesToFix = @()
if ($IssueNumber) {
# Single issue specified
$reviewResult = Get-IssueReviewResult -IssueNumber $IssueNumber -RepoRoot $repoRoot
if (-not $reviewResult.HasOverview -or -not $reviewResult.HasImplementationPlan) {
throw "Issue #$IssueNumber does not have review files. Run Start-BulkIssueReview.ps1 first."
}
$issuesToFix += @{
IssueNumber = $IssueNumber
OverviewPath = $reviewResult.OverviewPath
ImplementationPlanPath = $reviewResult.ImplementationPlanPath
}
} else {
# Find high-confidence issues
Info "`nSearching for high-confidence issues..."
Info " Min Feasibility Score: $MinFeasibilityScore"
Info " Min Clarity Score: $MinClarityScore"
Info " Max Effort: $MaxEffortDays days"
$highConfidence = Get-HighConfidenceIssues `
-RepoRoot $repoRoot `
-MinFeasibilityScore $MinFeasibilityScore `
-MinClarityScore $MinClarityScore `
-MaxEffortDays $MaxEffortDays
if ($highConfidence.Count -eq 0) {
Warn "No high-confidence issues found matching criteria."
Info "Try lowering the score thresholds or increasing MaxEffortDays."
return
}
$issuesToFix = $highConfidence
}
Info "`nIssues ready for auto-fix: $($issuesToFix.Count)"
Info ("-" * 80)
foreach ($issue in $issuesToFix) {
$scores = ""
if ($issue.FeasibilityScore) {
$scores = " [Feasibility: $($issue.FeasibilityScore), Clarity: $($issue.ClarityScore), Effort: $($issue.EffortDays)d]"
}
Info ("#{0,-6}{1}" -f $issue.IssueNumber, $scores)
}
Info ("-" * 80)
# In DryRun mode, still analyze plans but don't take action
if ($DryRun) {
Info "`nAnalyzing implementation plans (dry run)..."
foreach ($issue in $issuesToFix) {
$implPlanPath = Join-Path (Get-IssueReviewPath -RepoRoot $repoRoot -IssueNumber $issue.IssueNumber) 'implementation-plan.md'
if (Test-Path $implPlanPath) {
$planStatus = Get-ImplementationPlanStatus -ImplementationPlanPath $implPlanPath
$color = switch ($planStatus.Action) {
'ImplementFix' { 'Green' }
'CloseIssue' { 'Yellow' }
'AddComment' { 'Cyan' }
'LinkDuplicate' { 'Magenta' }
default { 'Gray' }
}
Write-Host (" #{0,-6} [{1,-20}] -> {2}" -f $issue.IssueNumber, $planStatus.Status, $planStatus.Action) -ForegroundColor $color
if ($planStatus.RelatedPR) {
$prInfo = "PR #$($planStatus.RelatedPR)"
if ($planStatus.ReleasedIn) {
$prInfo += " (released in $($planStatus.ReleasedIn))"
} elseif ($planStatus.Status -eq 'FixedButUnreleased') {
$prInfo += " (merged, awaiting release)"
}
Write-Host " $prInfo" -ForegroundColor DarkGray
}
if ($planStatus.DuplicateOf) {
Write-Host " Duplicate of #$($planStatus.DuplicateOf)" -ForegroundColor DarkGray
}
}
}
Warn "`nDry run mode - no actions taken."
return
}
# Confirm before proceeding (skip if -Force)
if (-not $Force) {
$confirm = Read-Host "`nProceed with fixing $($issuesToFix.Count) issues? (y/N)"
if ($confirm -notmatch '^[yY]') {
Info "Cancelled."
return
}
}
# Process issues
$results = @{
Succeeded = @()
Failed = @()
AlreadyResolved = @()
AwaitingRelease = @()
NeedsClarification = @()
Duplicates = @()
NoChanges = @()
}
foreach ($issue in $issuesToFix) {
try {
Info "`n" + ("=" * 60)
Info "PROCESSING ISSUE #$($issue.IssueNumber)"
Info ("=" * 60)
$result = Start-IssueFixInWorktree `
-IssueNumber $issue.IssueNumber `
-SourceRepoRoot $repoRoot `
-CLIType $CLIType `
-VSCodeProfile $VSCodeProfile `
-SkipWorktree:$SkipWorktree `
-DryRun:$DryRun
if ($result.SkippedCodeFix) {
# Action was taken but no code fix (e.g., closed issue, added comment)
switch -Wildcard ($result.ActionTaken) {
'*Closing*' { $results.AlreadyResolved += $issue.IssueNumber }
'*clarification*' { $results.NeedsClarification += $issue.IssueNumber }
'*duplicate*' { $results.Duplicates += $issue.IssueNumber }
'*merged*awaiting*' { $results.AwaitingRelease += $issue.IssueNumber }
'*merged but not yet released*' { $results.AwaitingRelease += $issue.IssueNumber }
default { $results.Succeeded += $issue.IssueNumber }
}
Success "✓ Issue #$($issue.IssueNumber) handled: $($result.ActionTaken)"
}
elseif ($result.HasChanges) {
$results.Succeeded += $issue.IssueNumber
Success "✓ Issue #$($issue.IssueNumber) fix completed with changes"
}
else {
$results.NoChanges += $issue.IssueNumber
Warn "⚠ Issue #$($issue.IssueNumber) fix ran but no code changes were made"
}
}
catch {
Err "✗ Issue #$($issue.IssueNumber) failed: $($_.Exception.Message)"
$results.Failed += $issue.IssueNumber
}
}
# Summary
Info "`n" + ("=" * 80)
Info "AUTO-FIX COMPLETE"
Info ("=" * 80)
Info "Total issues: $($issuesToFix.Count)"
if ($results.Succeeded.Count -gt 0) {
Success "Code fixes: $($results.Succeeded.Count)"
}
if ($results.AlreadyResolved.Count -gt 0) {
Success "Already resolved: $($results.AlreadyResolved.Count) (issues closed)"
}
if ($results.AwaitingRelease.Count -gt 0) {
Info "Awaiting release: $($results.AwaitingRelease.Count) (fix merged, pending release)"
}
if ($results.NeedsClarification.Count -gt 0) {
Warn "Need clarification: $($results.NeedsClarification.Count) (comments added)"
}
if ($results.Duplicates.Count -gt 0) {
Warn "Duplicates: $($results.Duplicates.Count) (issues closed)"
}
if ($results.NoChanges.Count -gt 0) {
Warn "No changes made: $($results.NoChanges.Count)"
}
if ($results.Failed.Count -gt 0) {
Err "Failed: $($results.Failed.Count)"
Err "Failed issues: $($results.Failed -join ', ')"
}
Info ("=" * 80)
if (-not $SkipWorktree -and ($results.Succeeded.Count -gt 0 -or $results.NoChanges.Count -gt 0)) {
Info "`nWorktrees created. Use 'git worktree list' to see all worktrees."
Info "To clean up: Delete-Worktree.ps1 -Branch issue/<number>"
}
return $results
}
catch {
Err "Error: $($_.Exception.Message)"
exit 1
}
#endregion

21
.github/skills/issue-review/LICENSE.txt vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

114
.github/skills/issue-review/SKILL.md vendored Normal file
View File

@@ -0,0 +1,114 @@
---
name: issue-review
description: Analyze GitHub issues for feasibility and implementation planning. Use when asked to review an issue, analyze if an issue is fixable, evaluate issue complexity, create implementation plan for an issue, triage issues, assess technical feasibility, or estimate effort for an issue. Outputs structured analysis including feasibility score, clarity score, effort estimate, and detailed implementation plan.
license: Complete terms in LICENSE.txt
---
# Issue Review Skill
Analyze GitHub issues to determine technical feasibility, requirement clarity, and create detailed implementation plans for PowerToys.
## Skill Contents
This skill is **self-contained** with all required resources:
```
.github/skills/issue-review/
├── SKILL.md # This file
├── LICENSE.txt # MIT License
├── scripts/
│ ├── IssueReviewLib.ps1 # Shared library functions
│ └── Start-BulkIssueReview.ps1 # Main review script
└── references/
└── review-issue.prompt.md # Full AI prompt template
```
## Output Directory
All generated artifacts are placed under `Generated Files/issueReview/<issue-number>/` at the repository root (gitignored).
```
Generated Files/issueReview/
└── <issue-number>/
├── overview.md # High-level assessment with scores
├── implementation-plan.md # Detailed step-by-step fix plan
└── _raw-issue.json # Cached issue data from GitHub
```
## When to Use This Skill
- Review a specific GitHub issue for feasibility
- Analyze whether an issue can be fixed by AI
- Create an implementation plan for an issue
- Triage issues by complexity and clarity
- Estimate effort for fixing an issue
- Evaluate technical requirements of an issue
## Prerequisites
- GitHub CLI (`gh`) installed and authenticated
- PowerShell 7+ for running scripts
## Required Variables
⚠️ **Before starting**, confirm `{{IssueNumber}}` with the user. If not provided, **ASK**: "What issue number should I review?"
| Variable | Description | Example |
|----------|-------------|---------|
| `{{IssueNumber}}` | GitHub issue number to analyze | `44044` |
## Workflow
### Step 1: Run Issue Review
Execute the review script (use paths relative to this skill folder):
```powershell
# From repo root
.github/skills/issue-review/scripts/Start-BulkIssueReview.ps1 -IssueNumber {{IssueNumber}}
```
This will:
1. Fetch issue details from GitHub
2. Analyze the codebase for relevant files
3. Generate `overview.md` with feasibility assessment
4. Generate `implementation-plan.md` with detailed steps
### Step 2: Review Output
Check the generated files at `Generated Files/issueReview/{{IssueNumber}}/`:
| File | Contains |
|------|----------|
| `overview.md` | Feasibility score (0-100), Clarity score (0-100), Effort estimate, Risk assessment |
| `implementation-plan.md` | Step-by-step implementation with file paths, code snippets, test requirements |
### Step 3: Interpret Scores
| Score Range | Interpretation |
|-------------|----------------|
| 80-100 | High confidence - straightforward fix |
| 60-79 | Medium confidence - some complexity |
| 40-59 | Low confidence - significant challenges |
| 0-39 | Very low - may need human intervention |
## Batch Review
To review multiple issues at once:
```powershell
.github/skills/issue-review/scripts/Start-BulkIssueReview.ps1 -IssueNumbers 44044, 32950, 45029
```
## AI Prompt Reference
For manual AI invocation, the full prompt is at:
- `references/review-issue.prompt.md` (relative to this skill folder)
## Troubleshooting
| Problem | Solution |
|---------|----------|
| Issue not found | Verify issue number exists: `gh issue view {{IssueNumber}}` |
| No implementation plan | Issue may be unclear - check `overview.md` for clarity score |
| Script errors | Ensure you're in the PowerToys repo root |

View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"github-artifacts": {
"command": "cmd",
"args": ["/c", "for /f %i in ('git rev-parse --show-toplevel') do node %i/tools/mcp/github-artifacts/launch.js"],
"tools": ["*"]
}
}
}

View File

@@ -0,0 +1,165 @@
---
agent: 'agent'
description: 'Review a GitHub issue, score it (0-100), and generate an implementation plan'
---
# Review GitHub Issue
## Goal
For **#{{issue_number}}** produce:
1) `Generated Files/issueReview/{{issue_number}}/overview.md`
2) `Generated Files/issueReview/{{issue_number}}/implementation-plan.md`
## Inputs
Figure out required inputs {{issue_number}} from the invocation context; if anything is missing, ask for the value or note it as a gap.
# CONTEXT (brief)
Ground evidence using `gh issue view {{issue_number}} --json number,title,body,author,createdAt,updatedAt,state,labels,milestone,reactions,comments,linkedPullRequests`, download images via MCP `github_issue_images` to better understand the issue context. Finally, use MCP `github_issue_attachments` to download logs with parameter `extractFolder` as `Generated Files/issueReview/{{issue_number}}/logs`, and analyze the downloaded logs if available to identify relevant issues. Locate the source code in the current workspace (use `rg`/`git grep` as needed). Link related issues and PRs.
## When to call MCP tools
If the following MCP "github-artifacts" tools are available in the environment, use them:
- `github_issue_images`: use when the issue/PR likely contains screenshots or other visual evidence (UI bugs, glitches, design problems).
- `github_issue_attachments`: use when the issue/PR mentions attached ZIPs (PowerToysReport_*.zip, logs.zip, debug.zip) or asks to analyze logs/diagnostics. Always provide `extractFolder` as `Generated Files/issueReview/{{issue_number}}/logs`
If these tools are not available (not listed by the runtime), start the MCP server "github-artifacts" first.
# OVERVIEW.MD
## Summary
Issue, state, milestone, labels. **Signals**: 👍/❤️/👎, comment count, last activity, linked PRs.
## At-a-Glance Score Table
Present all ratings in a compact table for quick scanning:
| Dimension | Score | Assessment | Key Drivers |
|-----------|-------|------------|-------------|
| **A) Business Importance** | X/100 | Low/Medium/High | Top 2 factors with scores |
| **B) Community Excitement** | X/100 | Low/Medium/High | Top 2 factors with scores |
| **C) Technical Feasibility** | X/100 | Low/Medium/High | Top 2 factors with scores |
| **D) Requirement Clarity** | X/100 | Low/Medium/High | Top 2 factors with scores |
| **Overall Priority** | X/100 | Low/Medium/High/Critical | Average or weighted summary |
| **Effort Estimate** | X days (T-shirt) | XS/S/M/L/XL/XXL/Epic | Type: bug/feature/chore |
| **Similar Issues Found** | X open, Y closed | — | Quick reference to related work |
| **Potential Assignees** | @username, @username | — | Top contributors to module |
**Assessment bands**: 0-25 Low, 26-50 Medium, 51-75 High, 76-100 Critical
## Ratings (0100) — add evidence & short rationale
### A) Business Importance
- Labels (priority/security/regression): **≤35**
- Milestone/roadmap: **≤25**
- Customer/contract impact: **≤20**
- Unblocks/platform leverage: **≤20**
### B) Community Excitement
- 👍+❤️ normalized: **≤45**
- Comment volume & unique participants: **≤25**
- Recent activity (≤30d): **≤15**
- Duplicates/related issues: **≤15**
### C) Technical Feasibility
- Contained surface/clear seams: **≤30**
- Existing patterns/utilities: **≤25**
- Risk (perf/sec/compat) manageable: **≤25**
- Testability & CI support: **≤20**
### D) Requirement Clarity
- Behavior/repro/constraints: **≤60**
- Non-functionals (perf/sec/i18n/a11y): **≤25**
- Decision owners/acceptance signals: **≤15**
## Effort
Days + **T-shirt** (XS 0.51d, S 12, M 24, L 47, XL 714, XXL 1430, Epic >30).
Type/level: bug/feature/chore/docs/refactor/test-only; severity/value tier.
## Suggested Actions
Provide actionable recommendations for issue triage and assignment:
### A) Requirement Clarification (if Clarity score <50)
**When Requirement Clarity (Dimension D) is Medium or Low:**
- Identify specific gaps in issue description: missing repro steps, unclear expected behavior, undefined acceptance criteria, missing non-functional requirements
- Draft 3-5 clarifying questions to post as issue comment
- Suggest additional information needed: screenshots, logs, environment details, OS version, PowerToys version, error messages
- If behavior is ambiguous, propose 2-3 interpretation scenarios and ask reporter to confirm
- Example questions:
- "Can you provide exact steps to reproduce this issue?"
- "What is the expected behavior vs. what you're actually seeing?"
- "Does this happen on Windows 10, 11, or both?"
- "Can you attach a screenshot or screen recording?"
### B) Correct Label Suggestions
- Analyze issue type, module, and severity to suggest missing or incorrect labels
- Recommend labels from: `Issue-Bug`, `Issue-Feature`, `Issue-Docs`, `Issue-Task`, `Priority-High`, `Priority-Medium`, `Priority-Low`, `Needs-Triage`, `Needs-Author-Feedback`, `Product-<ModuleName>`, etc.
- If Requirement Clarity is low (<50), add `Needs-Author-Feedback` label
- If current labels are incorrect or incomplete, provide specific label changes with rationale
### C) Find Similar Issues & Past Fixes
- Search for similar issues using `gh issue list --search "keywords" --state all --json number,title,state,closedAt`
- Identify patterns: duplicate issues, related bugs, or similar feature requests
- For closed issues, find linked PRs that fixed them: check `linkedPullRequests` in issue data
- Provide 3-5 examples of similar issues with format: `#<number> - <title> (closed by PR #<pr>)` or `(still open)`
### D) Identify Subject Matter Experts
- Use git blame/log to find who fixed similar issues in the past
- Search for PR authors who touched relevant files: `git log --all --format='%aN' -- <file_paths> | sort | uniq -c | sort -rn | head -5`
- Check issue/PR history for frequent contributors to the affected module
- Suggest 2-3 potential assignees with context: `@<username> - <reason>` (e.g., "fixed similar rendering bug in #12345", "maintains FancyZones module")
### E) Semantic Search for Related Work
- Use semantic_search tool to find similar issues, code patterns, or past discussions
- Search queries should include: issue keywords, module names, error messages, feature descriptions
- Cross-reference semantic results with GitHub issue search for comprehensive coverage
**Output format for Suggested Actions section in overview.md:**
```markdown
## Suggested Actions
### Clarifying Questions (if Clarity <50)
Post these questions as issue comment to gather missing information:
1. <question>
2. <question>
3. <question>
**Recommended label**: `Needs-Author-Feedback`
### Label Recommendations
- Add: `<label>` - <reason>
- Remove: `<label>` - <reason>
- Current labels are appropriate ✓
### Similar Issues Found
1. #<number> - <title> (<state>, closed by PR #<pr> on <date>)
2. #<number> - <title> (<state>)
...
### Potential Assignees
- @<username> - <reason>
- @<username> - <reason>
### Related Code/Discussions
- <semantic search findings>
```
# IMPLEMENTATION-PLAN.MD
1) **Problem Framing** — restate problem; current vs expected; scope boundaries.
2) **Layers & Files** — layers (UI/domain/data/infra/build). For each, list **files/dirs to modify** and **new files** (exact paths + why). Prefer repo patterns; cite examples/PRs.
3) **Pattern Choices** — reuse existing; if new, justify trade-offs & transition.
4) **Fundamentals** (brief plan or N/A + reason):
- Performance (hot paths, allocs, caching/streaming)
- Security (validation, authN/Z, secrets, SSRF/XSS/CSRF)
- G11N/L10N (resources, number/date, pluralization)
- Compatibility (public APIs, formats, OS/runtime/toolchain)
- Extensibility (DI seams, options/flags, plugin points)
- Accessibility (roles, labels, focus, keyboard, contrast)
- SOLID & repo conventions (naming, folders, dependency direction)
5) **Logging & Exception Handling**
- Where to log; levels; structured fields; correlation/traces.
- What to catch vs rethrow; retries/backoff; user-visible errors.
- **Privacy**: never log secrets/PII; redaction policy.
6) **Telemetry (optional — business metrics only)**
- Events/metrics (name, when, props); success signal; privacy/sampling; dashboards/alerts.
7) **Risks & Mitigations** — flags/canary/shadow-write/config guards.
8) **Task Breakdown (agent-ready)** — table (leave a blank line before the header so Markdown renders correctly):
| Task | Intent | Files/Areas | Steps | Tests (brief) | Owner (Agent/Human) | Human interaction needed? (why) |
|---|---|---|---|---|---|---|
9) **Tests to Add (only)**
- **Unit**: targets, cases (success/edge/error), mocks/fixtures, path, notes.
- **UI** (if applicable): flows, locator strategy, env/data/flags, path, flake mitigation.

View File

@@ -0,0 +1,731 @@
# IssueReviewLib.ps1 - Shared helpers for bulk issue review automation
# Part of the PowerToys GitHub Copilot/Claude Code issue review system
#region Console Output Helpers
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Success { param([string]$Message) Write-Host $Message -ForegroundColor Green }
#endregion
#region Repository Helpers
function Get-RepoRoot {
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { throw 'Not inside a git repository.' }
return (Resolve-Path $root).Path
}
function Get-GeneratedFilesPath {
param([string]$RepoRoot)
return Join-Path $RepoRoot 'Generated Files'
}
function Get-IssueReviewPath {
param(
[string]$RepoRoot,
[int]$IssueNumber
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
return Join-Path $genFiles "issueReview/$IssueNumber"
}
function Get-IssueTitleFromOverview {
<#
.SYNOPSIS
Extract issue title from existing overview.md file.
.DESCRIPTION
Parses the overview.md to get the issue title without requiring GitHub CLI.
#>
param(
[Parameter(Mandatory)]
[string]$OverviewPath
)
if (-not (Test-Path $OverviewPath)) {
return $null
}
$content = Get-Content $OverviewPath -Raw
# Try to match title from Summary table: | **Title** | <title> |
if ($content -match '\*\*Title\*\*\s*\|\s*([^|]+)\s*\|') {
return $Matches[1].Trim()
}
# Try to match from header: # Issue #XXXX: <title>
if ($content -match '# Issue #\d+[:\s]+(.+)$' ) {
return $Matches[1].Trim()
}
# Try to match: # Issue #XXXX Review: <title>
if ($content -match '# Issue #\d+ Review[:\s]+(.+)$') {
return $Matches[1].Trim()
}
return $null
}
function Ensure-DirectoryExists {
param([string]$Path)
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
#endregion
#region GitHub Issue Query Helpers
function Get-GitHubIssues {
<#
.SYNOPSIS
Query GitHub issues by label, state, and sort order.
.PARAMETER Labels
Comma-separated list of labels to filter by (e.g., "bug,help wanted").
.PARAMETER State
Issue state: open, closed, or all. Default: open.
.PARAMETER Sort
Sort field: created, updated, comments, reactions. Default: created.
.PARAMETER Order
Sort order: asc or desc. Default: desc.
.PARAMETER Limit
Maximum number of issues to return. Default: 100.
.PARAMETER Repository
Repository in owner/repo format. Default: microsoft/PowerToys.
#>
param(
[string]$Labels,
[ValidateSet('open', 'closed', 'all')]
[string]$State = 'open',
[ValidateSet('created', 'updated', 'comments', 'reactions')]
[string]$Sort = 'created',
[ValidateSet('asc', 'desc')]
[string]$Order = 'desc',
[int]$Limit = 100,
[string]$Repository = 'microsoft/PowerToys'
)
$ghArgs = @('issue', 'list', '--repo', $Repository, '--state', $State, '--limit', $Limit)
if ($Labels) {
foreach ($label in ($Labels -split ',')) {
$ghArgs += @('--label', $label.Trim())
}
}
# Build JSON fields (use reactionGroups instead of reactions)
$jsonFields = 'number,title,state,labels,createdAt,updatedAt,author,reactionGroups,comments'
$ghArgs += @('--json', $jsonFields)
Info "Querying issues: gh $($ghArgs -join ' ')"
$result = & gh @ghArgs 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to query issues: $result"
}
$issues = $result | ConvertFrom-Json
# Sort by reactions if requested (gh CLI doesn't support this natively)
if ($Sort -eq 'reactions') {
$issues = $issues | ForEach-Object {
# reactionGroups is an array of {content, users} - sum up user counts
$totalReactions = ($_.reactionGroups | ForEach-Object { $_.users.totalCount } | Measure-Object -Sum).Sum
if (-not $totalReactions) { $totalReactions = 0 }
$_ | Add-Member -NotePropertyName 'totalReactions' -NotePropertyValue $totalReactions -PassThru
}
if ($Order -eq 'desc') {
$issues = $issues | Sort-Object -Property totalReactions -Descending
} else {
$issues = $issues | Sort-Object -Property totalReactions
}
}
return $issues
}
function Get-IssueDetails {
<#
.SYNOPSIS
Get detailed information about a specific issue.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[string]$Repository = 'microsoft/PowerToys'
)
$jsonFields = 'number,title,body,state,labels,createdAt,updatedAt,author,reactions,comments,linkedPullRequests,milestone'
$result = gh issue view $IssueNumber --repo $Repository --json $jsonFields 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to get issue #$IssueNumber`: $result"
}
return $result | ConvertFrom-Json
}
#endregion
#region CLI Detection and Execution
function Get-AvailableCLI {
<#
.SYNOPSIS
Detect which AI CLI is available: GitHub Copilot CLI or Claude Code.
.OUTPUTS
Returns object with: Name, Command, PromptArg
#>
# Check for standalone GitHub Copilot CLI (copilot command)
$copilotCLI = Get-Command 'copilot' -ErrorAction SilentlyContinue
if ($copilotCLI) {
return @{
Name = 'GitHub Copilot CLI'
Command = 'copilot'
Args = @('-p') # Non-interactive prompt mode
Type = 'copilot'
}
}
# Check for Claude Code CLI
$claudeCode = Get-Command 'claude' -ErrorAction SilentlyContinue
if ($claudeCode) {
return @{
Name = 'Claude Code CLI'
Command = 'claude'
Args = @()
Type = 'claude'
}
}
# Check for GitHub Copilot CLI via gh extension
$ghCopilot = Get-Command 'gh' -ErrorAction SilentlyContinue
if ($ghCopilot) {
$copilotCheck = gh extension list 2>&1 | Select-String -Pattern 'copilot'
if ($copilotCheck) {
return @{
Name = 'GitHub Copilot CLI (gh extension)'
Command = 'gh'
Args = @('copilot', 'suggest')
Type = 'gh-copilot'
}
}
}
# Check for VS Code CLI with Copilot
$code = Get-Command 'code' -ErrorAction SilentlyContinue
if ($code) {
return @{
Name = 'VS Code (Copilot Chat)'
Command = 'code'
Args = @()
Type = 'vscode'
}
}
return $null
}
function Invoke-AIReview {
<#
.SYNOPSIS
Invoke AI CLI to review a single issue.
.PARAMETER IssueNumber
The issue number to review.
.PARAMETER RepoRoot
Repository root path.
.PARAMETER CLIType
CLI type: 'claude', 'copilot', 'gh-copilot', or 'vscode'.
.PARAMETER WorkingDirectory
Working directory for the CLI command.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$RepoRoot,
[ValidateSet('claude', 'copilot', 'gh-copilot', 'vscode')]
[string]$CLIType = 'copilot',
[string]$WorkingDirectory
)
if (-not $WorkingDirectory) {
$WorkingDirectory = $RepoRoot
}
$promptFile = Join-Path $RepoRoot '.github/prompts/review-issue.prompt.md'
if (-not (Test-Path $promptFile)) {
throw "Prompt file not found: $promptFile"
}
# Prepare the prompt with issue number substitution
$promptContent = Get-Content $promptFile -Raw
$promptContent = $promptContent -replace '\{\{issue_number\}\}', $IssueNumber
# Create temp prompt file
$tempPromptDir = Join-Path $env:TEMP "issue-review-$IssueNumber"
Ensure-DirectoryExists -Path $tempPromptDir
$tempPromptFile = Join-Path $tempPromptDir "prompt.md"
$promptContent | Set-Content -Path $tempPromptFile -Encoding UTF8
# Build the prompt text for CLI
$promptText = "Review GitHub issue #$IssueNumber following the template in .github/prompts/review-issue.prompt.md. Generate overview.md and implementation-plan.md in 'Generated Files/issueReview/$IssueNumber/'"
switch ($CLIType) {
'copilot' {
# GitHub Copilot CLI (standalone copilot command)
# Use --yolo for full permissions (--allow-all-tools --allow-all-paths --allow-all-urls)
# Use -s (silent) for cleaner output in batch mode
# Enable ALL GitHub MCP tools (issues, PRs, repos, etc.) + github-artifacts for images/attachments
# MCP config path relative to repo root for github-artifacts tools
$mcpConfig = '@.github/skills/issue-review/references/mcp-config.json'
$args = @(
'--additional-mcp-config', $mcpConfig, # Load github-artifacts MCP for image/attachment analysis
'-p', $promptText, # Non-interactive prompt mode (exits after completion)
'--yolo', # Enable all permissions for automated execution
'-s', # Silent mode - output only agent response
'--enable-all-github-mcp-tools', # Enable ALL GitHub MCP tools (issues, PRs, search, etc.)
'--allow-tool', 'github-artifacts' # Also enable our custom github-artifacts MCP
)
return @{
Command = 'copilot'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
'claude' {
# Claude Code CLI
$args = @(
'--print', # Non-interactive mode
'--dangerously-skip-permissions',
'--prompt', $promptText
)
return @{
Command = 'claude'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
'gh-copilot' {
# GitHub Copilot CLI via gh
$args = @(
'copilot', 'suggest',
'-t', 'shell',
"Review GitHub issue #$IssueNumber and generate analysis files"
)
return @{
Command = 'gh'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
'vscode' {
# VS Code with Copilot - open with prompt
$args = @(
'--new-window',
$WorkingDirectory,
'--goto', $tempPromptFile
)
return @{
Command = 'code'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
}
}
#endregion
#region Parallel Job Management
function Start-ParallelIssueReviews {
<#
.SYNOPSIS
Start parallel issue reviews with throttling.
.PARAMETER Issues
Array of issue objects to review.
.PARAMETER MaxParallel
Maximum number of parallel jobs. Default: 20.
.PARAMETER CLIType
CLI type to use for reviews.
.PARAMETER RepoRoot
Repository root path.
.PARAMETER TimeoutMinutes
Timeout per issue in minutes. Default: 30.
.PARAMETER MaxRetries
Maximum number of retries for failed issues. Default: 2.
.PARAMETER RetryDelaySeconds
Delay between retries in seconds. Default: 10.
#>
param(
[Parameter(Mandatory)]
[array]$Issues,
[int]$MaxParallel = 20,
[ValidateSet('claude', 'copilot', 'gh-copilot', 'vscode')]
[string]$CLIType = 'copilot',
[Parameter(Mandatory)]
[string]$RepoRoot,
[int]$TimeoutMinutes = 30,
[int]$MaxRetries = 2,
[int]$RetryDelaySeconds = 10
)
$totalIssues = $Issues.Count
$completed = 0
$failed = @()
$succeeded = @()
$retryQueue = [System.Collections.Queue]::new()
Info "Starting parallel review of $totalIssues issues (max $MaxParallel concurrent, $MaxRetries retries)"
# Use PowerShell jobs for parallelization
$jobs = @()
$issueQueue = [System.Collections.Queue]::new($Issues)
while ($issueQueue.Count -gt 0 -or $jobs.Count -gt 0 -or $retryQueue.Count -gt 0) {
# Process retry queue when main queue is empty
if ($issueQueue.Count -eq 0 -and $retryQueue.Count -gt 0 -and $jobs.Count -lt $MaxParallel) {
$retryItem = $retryQueue.Dequeue()
Warn "🔄 Retrying issue #$($retryItem.IssueNumber) (attempt $($retryItem.Attempt + 1)/$($MaxRetries + 1))"
Start-Sleep -Seconds $RetryDelaySeconds
$issueQueue.Enqueue(@{ number = $retryItem.IssueNumber; _retryAttempt = $retryItem.Attempt + 1 })
}
# Start new jobs up to MaxParallel
while ($jobs.Count -lt $MaxParallel -and $issueQueue.Count -gt 0) {
$issue = $issueQueue.Dequeue()
$issueNum = $issue.number
$retryAttempt = if ($issue._retryAttempt) { $issue._retryAttempt } else { 0 }
$attemptInfo = if ($retryAttempt -gt 0) { " (retry $retryAttempt)" } else { "" }
Info "Starting review for issue #$issueNum$attemptInfo ($($totalIssues - $issueQueue.Count)/$totalIssues)"
$job = Start-Job -Name "Issue-$issueNum" -ScriptBlock {
param($IssueNumber, $RepoRoot, $CLIType)
Set-Location $RepoRoot
# Import the library in the job context
. "$RepoRoot/.github/review-tools/IssueReviewLib.ps1"
try {
$reviewCmd = Invoke-AIReview -IssueNumber $IssueNumber -RepoRoot $RepoRoot -CLIType $CLIType
# Execute the command using invocation operator (works for .ps1 scripts and executables)
Set-Location $reviewCmd.WorkingDirectory
$argList = $reviewCmd.Arguments
# Capture both stdout and stderr for better error reporting
$output = & $reviewCmd.Command @argList 2>&1
$exitCode = $LASTEXITCODE
# Get last 20 lines of output for error context
$outputLines = $output | Out-String
$lastLines = ($outputLines -split "`n" | Select-Object -Last 20) -join "`n"
# Check if output files were created (success indicator)
$overviewPath = Join-Path $RepoRoot "Generated Files/issueReview/$IssueNumber/overview.md"
$implPlanPath = Join-Path $RepoRoot "Generated Files/issueReview/$IssueNumber/implementation-plan.md"
$filesCreated = (Test-Path $overviewPath) -and (Test-Path $implPlanPath)
return @{
IssueNumber = $IssueNumber
Success = ($exitCode -eq 0) -or $filesCreated
ExitCode = $exitCode
FilesCreated = $filesCreated
Output = $lastLines
Error = if ($exitCode -ne 0 -and -not $filesCreated) { "Exit code: $exitCode`n$lastLines" } else { $null }
}
}
catch {
return @{
IssueNumber = $IssueNumber
Success = $false
ExitCode = -1
FilesCreated = $false
Output = $null
Error = $_.Exception.Message
}
}
} -ArgumentList $issueNum, $RepoRoot, $CLIType
$jobs += @{
Job = $job
IssueNumber = $issueNum
StartTime = Get-Date
RetryAttempt = $retryAttempt
}
}
# Check for completed jobs
$completedJobs = @()
foreach ($jobInfo in $jobs) {
$job = $jobInfo.Job
$issueNum = $jobInfo.IssueNumber
$startTime = $jobInfo.StartTime
$retryAttempt = $jobInfo.RetryAttempt
if ($job.State -eq 'Completed') {
$result = Receive-Job -Job $job
Remove-Job -Job $job -Force
if ($result.Success) {
Success "✓ Issue #$issueNum completed (files created: $($result.FilesCreated))"
$succeeded += $issueNum
$completed++
} else {
# Check if we should retry
if ($retryAttempt -lt $MaxRetries) {
$errorPreview = if ($result.Error) { ($result.Error -split "`n" | Select-Object -First 3) -join " | " } else { "Unknown error" }
Warn "⚠ Issue #$issueNum failed (will retry): $errorPreview"
$retryQueue.Enqueue(@{ IssueNumber = $issueNum; Attempt = $retryAttempt; LastError = $result.Error })
} else {
$errorMsg = if ($result.Error) { $result.Error } else { "Exit code: $($result.ExitCode)" }
Err "✗ Issue #$issueNum failed after $($retryAttempt + 1) attempts:"
Err " Error: $errorMsg"
$failed += @{ IssueNumber = $issueNum; Error = $errorMsg; Attempts = $retryAttempt + 1 }
$completed++
}
}
$completedJobs += $jobInfo
}
elseif ($job.State -eq 'Failed') {
$jobError = $job.ChildJobs[0].JobStateInfo.Reason.Message
Remove-Job -Job $job -Force
if ($retryAttempt -lt $MaxRetries) {
Warn "⚠ Issue #$issueNum job crashed (will retry): $jobError"
$retryQueue.Enqueue(@{ IssueNumber = $issueNum; Attempt = $retryAttempt; LastError = $jobError })
} else {
Err "✗ Issue #$issueNum job failed after $($retryAttempt + 1) attempts: $jobError"
$failed += @{ IssueNumber = $issueNum; Error = $jobError; Attempts = $retryAttempt + 1 }
$completed++
}
$completedJobs += $jobInfo
}
elseif ((Get-Date) - $startTime -gt [TimeSpan]::FromMinutes($TimeoutMinutes)) {
Stop-Job -Job $job -ErrorAction SilentlyContinue
Remove-Job -Job $job -Force
if ($retryAttempt -lt $MaxRetries) {
Warn "⏱ Issue #$issueNum timed out after $TimeoutMinutes min (will retry)"
$retryQueue.Enqueue(@{ IssueNumber = $issueNum; Attempt = $retryAttempt; LastError = "Timeout after $TimeoutMinutes minutes" })
} else {
Err "⏱ Issue #$issueNum timed out after $($retryAttempt + 1) attempts"
$failed += @{ IssueNumber = $issueNum; Error = "Timeout after $TimeoutMinutes minutes"; Attempts = $retryAttempt + 1 }
$completed++
}
$completedJobs += $jobInfo
}
}
# Remove completed jobs from active list
$jobs = $jobs | Where-Object { $_ -notin $completedJobs }
# Brief pause to avoid tight loop
if ($jobs.Count -gt 0) {
Start-Sleep -Seconds 2
}
}
# Extract just issue numbers for the failed list
$failedNumbers = $failed | ForEach-Object { $_.IssueNumber }
return @{
Total = $totalIssues
Succeeded = $succeeded
Failed = $failedNumbers
FailedDetails = $failed
}
}
#endregion
#region Issue Review Results Helpers
function Get-IssueReviewResult {
<#
.SYNOPSIS
Check if an issue has been reviewed and get its results.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$RepoRoot
)
$reviewPath = Get-IssueReviewPath -RepoRoot $RepoRoot -IssueNumber $IssueNumber
$result = @{
IssueNumber = $IssueNumber
Path = $reviewPath
HasOverview = $false
HasImplementationPlan = $false
OverviewPath = $null
ImplementationPlanPath = $null
}
$overviewPath = Join-Path $reviewPath 'overview.md'
$implPlanPath = Join-Path $reviewPath 'implementation-plan.md'
if (Test-Path $overviewPath) {
$result.HasOverview = $true
$result.OverviewPath = $overviewPath
}
if (Test-Path $implPlanPath) {
$result.HasImplementationPlan = $true
$result.ImplementationPlanPath = $implPlanPath
}
return $result
}
function Get-HighConfidenceIssues {
<#
.SYNOPSIS
Find issues with high confidence for auto-fix based on review results.
.PARAMETER RepoRoot
Repository root path.
.PARAMETER MinFeasibilityScore
Minimum Technical Feasibility score (0-100). Default: 70.
.PARAMETER MinClarityScore
Minimum Requirement Clarity score (0-100). Default: 60.
.PARAMETER MaxEffortDays
Maximum effort estimate in days. Default: 2 (S = Small).
.PARAMETER FilterIssueNumbers
Optional array of issue numbers to filter to. If specified, only these issues are considered.
#>
param(
[Parameter(Mandatory)]
[string]$RepoRoot,
[int]$MinFeasibilityScore = 70,
[int]$MinClarityScore = 60,
[int]$MaxEffortDays = 2,
[int[]]$FilterIssueNumbers = @()
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
$reviewDir = Join-Path $genFiles 'issueReview'
if (-not (Test-Path $reviewDir)) {
return @()
}
$highConfidence = @()
Get-ChildItem -Path $reviewDir -Directory | ForEach-Object {
$issueNum = [int]$_.Name
# Skip if filter is specified and this issue is not in the filter list
if ($FilterIssueNumbers.Count -gt 0 -and $issueNum -notin $FilterIssueNumbers) {
return
}
$overviewPath = Join-Path $_.FullName 'overview.md'
$implPlanPath = Join-Path $_.FullName 'implementation-plan.md'
if (-not (Test-Path $overviewPath) -or -not (Test-Path $implPlanPath)) {
return
}
# Parse overview.md to extract scores
$overview = Get-Content $overviewPath -Raw
# Extract scores using regex (looking for score table or inline scores)
$feasibility = 0
$clarity = 0
$effortDays = 999
# Try to extract from At-a-Glance Score Table
if ($overview -match 'Technical Feasibility[^\d]*(\d+)/100') {
$feasibility = [int]$Matches[1]
}
if ($overview -match 'Requirement Clarity[^\d]*(\d+)/100') {
$clarity = [int]$Matches[1]
}
# Match effort formats like "0.5-1 day", "1-2 days", "2-3 days" - extract the upper bound
if ($overview -match 'Effort Estimate[^|]*\|\s*[\d.]+(?:-(\d+))?\s*days?') {
if ($Matches[1]) {
$effortDays = [int]$Matches[1]
} elseif ($overview -match 'Effort Estimate[^|]*\|\s*(\d+)\s*days?') {
$effortDays = [int]$Matches[1]
}
}
# Also check for XS/S sizing in the table (e.g., "| XS |" or "| S |" or "(XS)" or "(S)")
if ($overview -match 'Effort Estimate[^|]*\|[^|]*\|\s*(XS|S)\b') {
# XS = 1 day, S = 2 days
if ($Matches[1] -eq 'XS') {
$effortDays = 1
} else {
$effortDays = 2
}
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(XS\)') {
$effortDays = 1
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(S\)') {
$effortDays = 2
}
if ($feasibility -ge $MinFeasibilityScore -and
$clarity -ge $MinClarityScore -and
$effortDays -le $MaxEffortDays) {
$highConfidence += @{
IssueNumber = $issueNum
FeasibilityScore = $feasibility
ClarityScore = $clarity
EffortDays = $effortDays
OverviewPath = $overviewPath
ImplementationPlanPath = $implPlanPath
}
}
}
return $highConfidence | Sort-Object -Property FeasibilityScore -Descending
}
#endregion
#region Worktree Integration
function Copy-IssueReviewToWorktree {
<#
.SYNOPSIS
Copy the Generated Files for an issue to a worktree.
.PARAMETER IssueNumber
The issue number.
.PARAMETER SourceRepoRoot
Source repository root (main repo).
.PARAMETER WorktreePath
Destination worktree path.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$SourceRepoRoot,
[Parameter(Mandatory)]
[string]$WorktreePath
)
$sourceReviewPath = Get-IssueReviewPath -RepoRoot $SourceRepoRoot -IssueNumber $IssueNumber
$destReviewPath = Get-IssueReviewPath -RepoRoot $WorktreePath -IssueNumber $IssueNumber
if (-not (Test-Path $sourceReviewPath)) {
throw "Issue review files not found at: $sourceReviewPath"
}
Ensure-DirectoryExists -Path $destReviewPath
# Copy all files from the issue review folder
Copy-Item -Path "$sourceReviewPath\*" -Destination $destReviewPath -Recurse -Force
Info "Copied issue review files to: $destReviewPath"
return $destReviewPath
}
#endregion
# Note: This script is dot-sourced, not imported as a module.
# All functions above are available after: . "path/to/IssueReviewLib.ps1"

View File

@@ -0,0 +1,238 @@
<#!
.SYNOPSIS
Bulk review GitHub issues using AI CLI (Claude Code or GitHub Copilot).
.DESCRIPTION
Queries GitHub issues by labels, state, and sort order, then kicks off parallel
AI-powered reviews for each issue. Results are stored in Generated Files/issueReview/<number>/.
.PARAMETER Labels
Comma-separated list of labels to filter issues (e.g., "bug,help wanted").
.PARAMETER State
Issue state: open, closed, or all. Default: open.
.PARAMETER Sort
Sort field: created, updated, comments, reactions. Default: created.
.PARAMETER Order
Sort order: asc or desc. Default: desc.
.PARAMETER Limit
Maximum number of issues to process. Default: 100.
.PARAMETER MaxParallel
Maximum parallel review jobs. Default: 20.
.PARAMETER CLIType
AI CLI to use: claude, gh-copilot, or vscode. Auto-detected if not specified.
.PARAMETER DryRun
List issues without starting reviews.
.PARAMETER SkipExisting
Skip issues that already have review files.
.PARAMETER Repository
Repository in owner/repo format. Default: microsoft/PowerToys.
.PARAMETER TimeoutMinutes
Timeout per issue review in minutes. Default: 30.
.EXAMPLE
# Review all open bugs sorted by reactions
./Start-BulkIssueReview.ps1 -Labels "bug" -Sort reactions -Order desc
.EXAMPLE
# Dry run to see which issues would be reviewed
./Start-BulkIssueReview.ps1 -Labels "help wanted" -DryRun
.EXAMPLE
# Review top 50 issues with Claude Code, max 10 parallel
./Start-BulkIssueReview.ps1 -Labels "Issue-Bug" -Limit 50 -MaxParallel 10 -CLIType claude
.EXAMPLE
# Skip already-reviewed issues
./Start-BulkIssueReview.ps1 -Labels "Issue-Feature" -SkipExisting
.NOTES
Requires: GitHub CLI (gh) authenticated, and either Claude Code CLI or VS Code with Copilot.
Results: Generated Files/issueReview/<issue_number>/overview.md and implementation-plan.md
#>
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$Labels,
[ValidateSet('open', 'closed', 'all')]
[string]$State = 'open',
[ValidateSet('created', 'updated', 'comments', 'reactions')]
[string]$Sort = 'created',
[ValidateSet('asc', 'desc')]
[string]$Order = 'desc',
[int]$Limit = 1000,
[int]$MaxParallel = 20,
[ValidateSet('claude', 'copilot', 'gh-copilot', 'vscode', 'auto')]
[string]$CLIType = 'auto',
[switch]$DryRun,
[switch]$SkipExisting,
[string]$Repository = 'microsoft/PowerToys',
[int]$TimeoutMinutes = 30,
[int]$MaxRetries = 2,
[int]$RetryDelaySeconds = 10,
[switch]$Force,
[switch]$Help
)
# Load library
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir/IssueReviewLib.ps1"
# Show help
if ($Help) {
Get-Help $MyInvocation.MyCommand.Path -Full
return
}
#region Main Script
try {
# Get repo root
$repoRoot = Get-RepoRoot
Info "Repository root: $repoRoot"
# Detect or validate CLI
if ($CLIType -eq 'auto') {
$cli = Get-AvailableCLI
if (-not $cli) {
throw "No AI CLI found. Please install Claude Code CLI or GitHub Copilot CLI extension."
}
$CLIType = $cli.Type
Info "Auto-detected CLI: $($cli.Name)"
}
# Query issues
Info "`nQuerying issues with filters:"
Info " Labels: $(if ($Labels) { $Labels } else { '(none)' })"
Info " State: $State"
Info " Sort: $Sort $Order"
Info " Limit: $Limit"
$issues = Get-GitHubIssues -Labels $Labels -State $State -Sort $Sort -Order $Order -Limit $Limit -Repository $Repository
if ($issues.Count -eq 0) {
Warn "No issues found matching the criteria."
return
}
Info "`nFound $($issues.Count) issues"
# Filter out existing reviews if requested
if ($SkipExisting) {
$originalCount = $issues.Count
$issues = $issues | Where-Object {
$result = Get-IssueReviewResult -IssueNumber $_.number -RepoRoot $repoRoot
-not ($result.HasOverview -and $result.HasImplementationPlan)
}
$skipped = $originalCount - $issues.Count
if ($skipped -gt 0) {
Info "Skipping $skipped issues with existing reviews"
}
}
if ($issues.Count -eq 0) {
Warn "All issues already have reviews. Nothing to do."
return
}
# Display issue list
Info "`nIssues to review:"
Info ("-" * 80)
foreach ($issue in $issues) {
$labels = ($issue.labels | ForEach-Object { $_.name }) -join ', '
$reactions = if ($issue.reactions) { $issue.reactions.totalCount } else { 0 }
Info ("#{0,-6} {1,-50} [👍{2}] [{3}]" -f $issue.number, ($issue.title.Substring(0, [Math]::Min(50, $issue.title.Length))), $reactions, $labels)
}
Info ("-" * 80)
if ($DryRun) {
Warn "`nDry run mode - no reviews started."
Info "Would review $($issues.Count) issues with CLI: $CLIType"
return
}
# Confirm before proceeding (skip if -Force)
if (-not $Force) {
$confirm = Read-Host "`nProceed with reviewing $($issues.Count) issues using $CLIType? (y/N)"
if ($confirm -notmatch '^[yY]') {
Info "Cancelled."
return
}
} else {
Info "`nProceeding with $($issues.Count) issues (Force mode)"
}
# Create output directory
$genFiles = Get-GeneratedFilesPath -RepoRoot $repoRoot
Ensure-DirectoryExists -Path (Join-Path $genFiles 'issueReview')
# Start parallel reviews
Info "`nStarting bulk review..."
Info " Max retries: $MaxRetries (delay: ${RetryDelaySeconds}s)"
$startTime = Get-Date
$results = Start-ParallelIssueReviews `
-Issues $issues `
-MaxParallel $MaxParallel `
-CLIType $CLIType `
-RepoRoot $repoRoot `
-TimeoutMinutes $TimeoutMinutes `
-MaxRetries $MaxRetries `
-RetryDelaySeconds $RetryDelaySeconds
$duration = (Get-Date) - $startTime
# Summary
Info "`n" + ("=" * 80)
Info "BULK REVIEW COMPLETE"
Info ("=" * 80)
Info "Total issues: $($results.Total)"
Success "Succeeded: $($results.Succeeded.Count)"
if ($results.Failed.Count -gt 0) {
Err "Failed: $($results.Failed.Count)"
Err "Failed issues: $($results.Failed -join ', ')"
Info ""
Info "Failed Issue Details:"
Info ("-" * 40)
foreach ($failedItem in $results.FailedDetails) {
Err " #$($failedItem.IssueNumber) (attempts: $($failedItem.Attempts)):"
$errorLines = ($failedItem.Error -split "`n" | Select-Object -First 5) -join "`n "
Err " $errorLines"
}
Info ("-" * 40)
}
Info "Duration: $($duration.ToString('hh\:mm\:ss'))"
Info "Output: $genFiles/issueReview/"
Info ("=" * 80)
# Return results for pipeline
return $results
}
catch {
Err "Error: $($_.Exception.Message)"
exit 1
}
#endregion

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,136 @@
---
name: issue-to-pr-cycle
description: End-to-end automation from issue analysis to PR creation and review. Use when asked to fix multiple issues automatically, run full issue cycle, batch process issues, automate issue resolution, create PRs for high-confidence issues, or process issues end-to-end. Orchestrates issue review, auto-fix, PR submission, and PR review in parallel batches.
license: Complete terms in LICENSE.txt
---
# Issue-to-PR Full Cycle Skill
Orchestrate the complete workflow from issue analysis to PR creation and review. Processes multiple issues in parallel with configurable confidence thresholds.
## Skill Contents
This skill is **self-contained** with all required resources:
```
.github/skills/issue-to-pr-cycle/
├── SKILL.md # This file
├── LICENSE.txt # MIT License
└── scripts/
└── Start-FullIssueCycle.ps1 # Main orchestration script
```
**Note**: This skill orchestrates other skills via their PowerShell scripts:
- `issue-review` skill scripts
- `issue-fix` skill scripts
- `submit-pr` skill scripts
- `pr-review` skill scripts
## Output
The skill produces:
1. Issue review files in `Generated Files/issueReview/<issue-number>/`
2. Git worktrees with fixes at `Q:/PowerToys-xxxx/`
3. Pull requests on GitHub
4. PR review files in `Generated Files/prReview/<pr-number>/`
## When to Use This Skill
- Process multiple issues end-to-end automatically
- Batch fix high-confidence issues
- Run full automation cycle for triaged issues
- Create PRs for multiple reviewed issues
- Automate issue-to-PR workflow
## Prerequisites
- GitHub CLI (`gh`) installed and authenticated
- Copilot CLI or Claude CLI installed
- PowerShell 7+ for running scripts
- Issues already reviewed (have `Generated Files/issueReview/` data)
## Quick Start
### Option 1: Dry Run First
See what would be processed without making changes:
```powershell
# From repo root
.github/skills/issue-to-pr-cycle/scripts/Start-FullIssueCycle.ps1 `
-MinFeasibilityScore 70 `
-MinClarityScore 70 `
-MaxEffortDays 10 `
-SkipExisting `
-DryRun
```
### Option 2: Run Full Cycle
Process all matching issues:
```powershell
.github/skills/issue-to-pr-cycle/scripts/Start-FullIssueCycle.ps1 `
-MinFeasibilityScore 70 `
-MinClarityScore 70 `
-MaxEffortDays 10 `
-SkipExisting `
-CLIType copilot `
-Force
```
## CLI Options
| Parameter | Description | Default |
|-----------|-------------|---------|
| `-MinFeasibilityScore` | Minimum technical feasibility score (0-100) | `70` |
| `-MinClarityScore` | Minimum requirement clarity score (0-100) | `70` |
| `-MaxEffortDays` | Maximum effort estimate in days | `10` |
| `-ExcludeIssues` | Array of issue numbers to skip | `@()` |
| `-SkipExisting` | Skip issues that already have PRs | `false` |
| `-CLIType` | AI CLI to use: `copilot` or `claude` | `copilot` |
| `-FixThrottleLimit` | Parallel limit for fix phase | `5` |
| `-PRThrottleLimit` | Parallel limit for PR phase | `5` |
| `-ReviewThrottleLimit` | Parallel limit for review phase | `3` |
| `-DryRun` | Show what would be done | `false` |
| `-Force` | Skip confirmation prompts | `false` |
## Workflow Phases
```
┌─────────────────────────────────────────────────────────────┐
│ PHASE 1: Auto-Fix Issues (Parallel) │
│ Uses: issue-fix skill scripts │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ PHASE 2: Submit PRs (Parallel) │
│ Uses: submit-pr skill scripts │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ PHASE 3: Review PRs (Parallel) │
│ Uses: pr-review skill scripts │
└─────────────────────────────────────────────────────────────┘
```
## Related Skills
This skill orchestrates (via PowerShell, not skill-to-skill):
| Skill | Script Location | Purpose |
|-------|-----------------|---------|
| `issue-review` | `.github/skills/issue-review/scripts/` | Analyze issues |
| `issue-fix` | `.github/skills/issue-fix/scripts/` | Create fixes |
| `submit-pr` | `.github/skills/submit-pr/scripts/` | Create PRs |
| `pr-review` | `.github/skills/pr-review/scripts/` | Review PRs |
You can use each skill independently for finer control.
## Troubleshooting
| Problem | Solution |
|---------|----------|
| No issues found | Lower score thresholds or run more issue reviews |
| All issues skipped | Remove `-SkipExisting` or check for existing PRs |
| Parallel failures | Reduce throttle limits |

View File

@@ -0,0 +1,123 @@
# IssueReviewLib.ps1 - Helpers for full issue-to-PR cycle workflow
# Part of the PowerToys GitHub Copilot/Claude Code issue review system
# This is a trimmed version with only what issue-to-pr-cycle needs
#region Console Output Helpers
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Success { param([string]$Message) Write-Host $Message -ForegroundColor Green }
#endregion
#region Repository Helpers
function Get-RepoRoot {
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { throw 'Not inside a git repository.' }
return (Resolve-Path $root).Path
}
function Get-GeneratedFilesPath {
param([string]$RepoRoot)
return Join-Path $RepoRoot 'Generated Files'
}
#endregion
#region Issue Review Results Helpers
function Get-HighConfidenceIssues {
<#
.SYNOPSIS
Find issues with high confidence for auto-fix based on review results.
.PARAMETER RepoRoot
Repository root path.
.PARAMETER MinFeasibilityScore
Minimum Technical Feasibility score (0-100). Default: 70.
.PARAMETER MinClarityScore
Minimum Requirement Clarity score (0-100). Default: 60.
.PARAMETER MaxEffortDays
Maximum effort estimate in days. Default: 2 (S = Small).
.PARAMETER FilterIssueNumbers
Optional array of issue numbers to filter to. If specified, only these issues are considered.
#>
param(
[Parameter(Mandatory)]
[string]$RepoRoot,
[int]$MinFeasibilityScore = 70,
[int]$MinClarityScore = 60,
[int]$MaxEffortDays = 2,
[int[]]$FilterIssueNumbers = @()
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
$reviewDir = Join-Path $genFiles 'issueReview'
if (-not (Test-Path $reviewDir)) {
return @()
}
$highConfidence = @()
Get-ChildItem -Path $reviewDir -Directory | ForEach-Object {
$issueNum = [int]$_.Name
# Skip if filter is specified and this issue is not in the filter list
if ($FilterIssueNumbers.Count -gt 0 -and $issueNum -notin $FilterIssueNumbers) {
return
}
$overviewPath = Join-Path $_.FullName 'overview.md'
$implPlanPath = Join-Path $_.FullName 'implementation-plan.md'
if (-not (Test-Path $overviewPath) -or -not (Test-Path $implPlanPath)) {
return
}
# Parse overview.md to extract scores
$overview = Get-Content $overviewPath -Raw
# Extract scores using regex (looking for score table or inline scores)
$feasibility = 0
$clarity = 0
$effortDays = 999
# Try to extract from At-a-Glance Score Table
if ($overview -match 'Technical Feasibility[^\d]*(\d+)/100') {
$feasibility = [int]$Matches[1]
}
if ($overview -match 'Requirement Clarity[^\d]*(\d+)/100') {
$clarity = [int]$Matches[1]
}
# Match effort formats like "0.5-1 day", "1-2 days", "2-3 days" - extract the upper bound
if ($overview -match 'Effort Estimate[^|]*\|\s*[\d.]+(?:-(\d+))?\s*days?') {
if ($Matches[1]) {
$effortDays = [int]$Matches[1]
} elseif ($overview -match 'Effort Estimate[^|]*\|\s*(\d+)\s*days?') {
$effortDays = [int]$Matches[1]
}
}
# Also check for XS/S sizing in the table
if ($overview -match 'Effort Estimate[^|]*\|[^|]*\|\s*(XS|S)\b') {
if ($Matches[1] -eq 'XS') { $effortDays = 1 } else { $effortDays = 2 }
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(XS\)') {
$effortDays = 1
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(S\)') {
$effortDays = 2
}
if ($feasibility -ge $MinFeasibilityScore -and
$clarity -ge $MinClarityScore -and
$effortDays -le $MaxEffortDays) {
$highConfidence += @{
IssueNumber = $issueNum
FeasibilityScore = $feasibility
ClarityScore = $clarity
EffortDays = $effortDays
OverviewPath = $overviewPath
ImplementationPlanPath = $implPlanPath
}
}
}
return $highConfidence | Sort-Object -Property FeasibilityScore -Descending
}
#endregion

View File

@@ -0,0 +1,478 @@
<#!
.SYNOPSIS
Run the complete issue-to-PR cycle: fix issues, create PRs, review, and fix comments.
.DESCRIPTION
Orchestrates the full workflow:
1. Find high-confidence issues matching criteria
2. Create worktrees and run auto-fix for each issue
3. Commit changes and create PRs
4. Run PR review workflow (assign Copilot, review, fix comments)
.PARAMETER MinFeasibilityScore
Minimum Technical Feasibility score. Default: 70.
.PARAMETER MinClarityScore
Minimum Requirement Clarity score. Default: 70.
.PARAMETER MaxEffortDays
Maximum effort in days. Default: 10.
.PARAMETER ExcludeIssues
Array of issue numbers to exclude (already processed).
.PARAMETER CLIType
AI CLI to use: copilot or claude. Default: copilot.
.PARAMETER DryRun
Show what would be done without executing.
.PARAMETER SkipExisting
Skip issues that already have worktrees or PRs.
.EXAMPLE
./Start-FullIssueCycle.ps1 -MinFeasibilityScore 70 -MinClarityScore 70 -MaxEffortDays 10
.EXAMPLE
./Start-FullIssueCycle.ps1 -ExcludeIssues 44044,45029,32950,35703,44480 -DryRun
#>
[CmdletBinding()]
param(
[string]$Labels = '',
[int]$Limit = 500, # GitHub API max is 1000, default to 500 to get most issues
[int]$MinFeasibilityScore = 70,
[int]$MinClarityScore = 70,
[int]$MaxEffortDays = 10,
[int[]]$ExcludeIssues = @(),
[ValidateSet('copilot', 'claude')]
[string]$CLIType = 'copilot',
[int]$FixThrottleLimit = 5,
[int]$PRThrottleLimit = 5,
[int]$ReviewThrottleLimit = 3,
[switch]$DryRun,
[switch]$SkipExisting,
[switch]$SkipReview,
[switch]$Force,
[switch]$Help
)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$skillsDir = Split-Path -Parent (Split-Path -Parent $scriptDir) # .github/skills
. (Join-Path $scriptDir 'IssueReviewLib.ps1')
# Paths to other skills' scripts
$issueFixScript = Join-Path $skillsDir 'issue-fix/scripts/Start-IssueAutoFix.ps1'
$submitPRScript = Join-Path $skillsDir 'submit-pr/scripts/Submit-IssueFixes.ps1'
$prReviewScript = Join-Path $skillsDir 'pr-review/scripts/Start-PRReviewWorkflow.ps1'
$repoRoot = Get-RepoRoot
$worktreeLib = Join-Path $repoRoot 'tools/build/WorktreeLib.ps1'
if (Test-Path $worktreeLib) {
. $worktreeLib
}
if ($Help) {
Get-Help $MyInvocation.MyCommand.Path -Full
return
}
#region Helper Functions
function Get-ExistingIssuePRs {
<#
.SYNOPSIS
Get ALL issues that already have PRs (open, closed, or merged) - checking GitHub directly.
#>
param(
[int[]]$IssueNumbers
)
$existingPRs = @{}
foreach ($issueNum in $IssueNumbers) {
# Check if there's a PR that mentions this issue (any state: open, closed, merged)
$prs = gh pr list --search "fixes #$issueNum OR closes #$issueNum OR resolves #$issueNum" --state all --json number,url,headRefName,state 2>$null | ConvertFrom-Json
if ($prs -and $prs.Count -gt 0) {
$existingPRs[$issueNum] = @{
PRNumber = $prs[0].number
PRUrl = $prs[0].url
Branch = $prs[0].headRefName
State = $prs[0].state
}
continue
}
# Also check for branch pattern issue/<number>* (any state)
$branchPrs = gh pr list --head "issue/$issueNum" --state all --json number,url,headRefName,state 2>$null | ConvertFrom-Json
if (-not $branchPrs -or $branchPrs.Count -eq 0) {
# Try with wildcard search via gh api
$branchPrs = gh pr list --state all --json number,url,headRefName,state 2>$null | ConvertFrom-Json | Where-Object { $_.headRefName -like "issue/$issueNum*" }
}
if ($branchPrs -and $branchPrs.Count -gt 0) {
$existingPRs[$issueNum] = @{
PRNumber = $branchPrs[0].number
PRUrl = $branchPrs[0].url
Branch = $branchPrs[0].headRefName
State = $branchPrs[0].state
}
}
}
return $existingPRs
}
function Get-ExistingWorktrees {
<#
.SYNOPSIS
Get issues that already have worktrees.
#>
$existingWorktrees = @{}
$worktrees = Get-WorktreeEntries | Where-Object { $_.Branch -like 'issue/*' }
foreach ($wt in $worktrees) {
if ($wt.Branch -match 'issue/(\d+)') {
$issueNum = [int]$Matches[1]
$existingWorktrees[$issueNum] = $wt.Path
}
}
return $existingWorktrees
}
#endregion
#region Main Script
try {
$startTime = Get-Date
Info "=" * 80
Info "FULL ISSUE-TO-PR CYCLE"
Info "=" * 80
Info "Repository root: $repoRoot"
Info "CLI type: $CLIType"
if ($Labels) {
Info "Labels filter: $Labels"
}
Info "Criteria: Feasibility >= $MinFeasibilityScore, Clarity >= $MinClarityScore, Effort <= $MaxEffortDays days"
# Step 0: Review issues first (if labels specified and not skipping review)
if ($Labels -and -not $SkipReview) {
Info "`n" + ("=" * 60)
Info "STEP 0: Reviewing issues with label '$Labels'"
Info ("=" * 60)
$reviewScript = Join-Path $scriptDir '../../issue-review/scripts/Start-BulkIssueReview.ps1'
if (Test-Path $reviewScript) {
$reviewArgs = @{
Labels = $Labels
Limit = $Limit
CLIType = $CLIType
Force = $Force
}
if ($DryRun) {
Info "[DRY RUN] Would run: Start-BulkIssueReview.ps1 -Labels '$Labels' -Limit $Limit -CLIType $CLIType -Force"
} else {
Info "Running bulk issue review..."
& $reviewScript @reviewArgs
}
} else {
Warn "Review script not found at: $reviewScript"
Warn "Proceeding with existing review data..."
}
}
# Step 1: Find high-confidence issues
Info "`n" + ("=" * 60)
Info "STEP 1: Finding high-confidence issues"
Info ("=" * 60)
# If labels specified, get the list of issue numbers with that label first
# This ensures we ONLY look at issues with the specified label, not all reviewed issues
$filterIssueNumbers = @()
if ($Labels) {
Info "Fetching issues with label '$Labels' from GitHub..."
$labeledIssues = gh issue list --repo microsoft/PowerToys --label "$Labels" --state open --limit $Limit --json number 2>$null | ConvertFrom-Json
$filterIssueNumbers = @($labeledIssues | ForEach-Object { $_.number })
Info "Found $($filterIssueNumbers.Count) issues with label '$Labels'"
}
$highConfidence = Get-HighConfidenceIssues `
-RepoRoot $repoRoot `
-MinFeasibilityScore $MinFeasibilityScore `
-MinClarityScore $MinClarityScore `
-MaxEffortDays $MaxEffortDays `
-FilterIssueNumbers $filterIssueNumbers
Info "Found $($highConfidence.Count) high-confidence issues matching criteria"
if ($highConfidence.Count -eq 0) {
Warn "No issues found matching criteria."
return
}
# Get issue numbers for checking
$issueNumbers = $highConfidence | ForEach-Object { $_.IssueNumber }
# Get existing PRs to skip (check GitHub directly)
Info "Checking for existing PRs..."
$existingPRs = Get-ExistingIssuePRs -IssueNumbers $issueNumbers
Info "Found $($existingPRs.Count) issues with existing PRs"
# Filter out excluded issues and those with existing PRs
$issuesToProcess = $highConfidence | Where-Object {
$issueNum = $_.IssueNumber
$excluded = $issueNum -in $ExcludeIssues
$hasPR = $existingPRs.ContainsKey($issueNum)
if ($excluded) {
Info " Excluding #$issueNum (in exclude list)"
}
if ($hasPR -and $SkipExisting) {
$prState = $existingPRs[$issueNum].State
Info " Skipping #$issueNum (has $prState PR #$($existingPRs[$issueNum].PRNumber))"
}
-not $excluded -and (-not $hasPR -or -not $SkipExisting)
}
if ($issuesToProcess.Count -eq 0) {
Warn "No new issues to process after filtering."
return
}
Info "`nIssues to process: $($issuesToProcess.Count)"
Info ("-" * 80)
foreach ($issue in $issuesToProcess) {
$prInfo = if ($existingPRs.ContainsKey($issue.IssueNumber)) {
$state = $existingPRs[$issue.IssueNumber].State
" [has $state PR #$($existingPRs[$issue.IssueNumber].PRNumber)]"
} else { "" }
Info ("#{0,-6} [F:{1}, C:{2}, E:{3}d]{4}" -f $issue.IssueNumber, $issue.FeasibilityScore, $issue.ClarityScore, $issue.EffortDays, $prInfo)
}
Info ("-" * 80)
if ($DryRun) {
Warn "`nDry run mode - showing what would be done:"
Info " 1. Create worktrees for $($issuesToProcess.Count) issues (parallel)"
Info " 2. Run Copilot auto-fix in each worktree (parallel)"
Info " 3. Commit and create PRs (parallel)"
Info " 4. Run PR review workflow (parallel)"
return
}
# Confirm
if (-not $Force) {
$confirm = Read-Host "`nProceed with full cycle for $($issuesToProcess.Count) issues? (y/N)"
if ($confirm -notmatch '^[yY]') {
Info "Cancelled."
return
}
}
# Track results
$results = @{
FixSucceeded = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
FixFailed = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
PRCreated = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
PRFailed = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
PRSkipped = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
ReviewSucceeded = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
ReviewFailed = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
}
# ========================================
# PHASE 1: Create worktrees and fix issues (PARALLEL)
# ========================================
Info "`n" + ("=" * 60)
Info "PHASE 1: Auto-Fix Issues (Parallel)"
Info ("=" * 60)
$issuesNeedingFix = $issuesToProcess | Where-Object { -not $existingPRs.ContainsKey($_.IssueNumber) }
$issuesWithPR = $issuesToProcess | Where-Object { $existingPRs.ContainsKey($_.IssueNumber) }
Info "Issues needing fix: $($issuesNeedingFix.Count)"
Info "Issues with existing PR (skip to review): $($issuesWithPR.Count)"
if ($issuesNeedingFix.Count -gt 0) {
$issuesNeedingFix | ForEach-Object -ThrottleLimit $FixThrottleLimit -Parallel {
$issue = $_
$issueNum = $issue.IssueNumber
$issueFixScript = $using:issueFixScript
$CLIType = $using:CLIType
$results = $using:results
try {
Write-Host "[Issue #$issueNum] Starting auto-fix..." -ForegroundColor Cyan
& $issueFixScript -IssueNumber $issueNum -CLIType $CLIType -Force 2>&1 | Out-Null
$results.FixSucceeded.Add($issueNum)
Write-Host "[Issue #$issueNum] ✓ Fix completed" -ForegroundColor Green
}
catch {
$results.FixFailed.Add(@{ IssueNumber = $issueNum; Error = $_.Exception.Message })
Write-Host "[Issue #$issueNum] ✗ Fix failed: $($_.Exception.Message)" -ForegroundColor Red
}
}
}
Info "`nPhase 1 complete: $($results.FixSucceeded.Count) succeeded, $($results.FixFailed.Count) failed"
# ========================================
# PHASE 2: Commit and create PRs (PARALLEL)
# ========================================
Info "`n" + ("=" * 60)
Info "PHASE 2: Submit PRs (Parallel)"
Info ("=" * 60)
$fixedIssues = $results.FixSucceeded.ToArray()
if ($fixedIssues.Count -gt 0) {
$fixedIssues | ForEach-Object -ThrottleLimit $PRThrottleLimit -Parallel {
$issueNum = $_
$submitPRScript = $using:submitPRScript
$CLIType = $using:CLIType
$results = $using:results
try {
Write-Host "[Issue #$issueNum] Creating PR..." -ForegroundColor Cyan
$submitResult = & $submitPRScript -IssueNumbers $issueNum -CLIType $CLIType -Force 2>&1
# Parse output to find PR URL
$prUrl = $null
$prNum = 0
if ($submitResult -match 'https://github.com/[^/]+/[^/]+/pull/(\d+)') {
$prUrl = $Matches[0]
$prNum = [int]$Matches[1]
}
if ($prNum -gt 0) {
$results.PRCreated.Add(@{ IssueNumber = $issueNum; PRNumber = $prNum; PRUrl = $prUrl })
Write-Host "[Issue #$issueNum] ✓ PR #$prNum created" -ForegroundColor Green
} else {
# Check if PR was already created
$existingPr = gh pr list --head "issue/$issueNum" --state open --json number,url 2>$null | ConvertFrom-Json
if ($existingPr -and $existingPr.Count -gt 0) {
$results.PRSkipped.Add(@{ IssueNumber = $issueNum; PRNumber = $existingPr[0].number; PRUrl = $existingPr[0].url; Reason = "Already exists" })
Write-Host "[Issue #$issueNum] PR already exists: #$($existingPr[0].number)" -ForegroundColor Yellow
} else {
$results.PRFailed.Add(@{ IssueNumber = $issueNum; Error = "No PR created" })
Write-Host "[Issue #$issueNum] ✗ PR creation failed" -ForegroundColor Red
}
}
}
catch {
$results.PRFailed.Add(@{ IssueNumber = $issueNum; Error = $_.Exception.Message })
Write-Host "[Issue #$issueNum] ✗ PR failed: $($_.Exception.Message)" -ForegroundColor Red
}
}
}
Info "`nPhase 2 complete: $($results.PRCreated.Count) created, $($results.PRSkipped.Count) skipped, $($results.PRFailed.Count) failed"
# ========================================
# PHASE 3: Review PRs (PARALLEL)
# ========================================
Info "`n" + ("=" * 60)
Info "PHASE 3: Review PRs (Parallel)"
Info ("=" * 60)
# Collect all PRs to review (newly created + existing)
$prsToReview = @()
foreach ($pr in $results.PRCreated.ToArray()) {
$prsToReview += @{ IssueNumber = $pr.IssueNumber; PRNumber = $pr.PRNumber }
}
foreach ($pr in $results.PRSkipped.ToArray()) {
$prsToReview += @{ IssueNumber = $pr.IssueNumber; PRNumber = $pr.PRNumber }
}
foreach ($issue in $issuesWithPR) {
$prInfo = $existingPRs[$issue.IssueNumber]
$prsToReview += @{ IssueNumber = $issue.IssueNumber; PRNumber = $prInfo.PRNumber }
}
Info "PRs to review: $($prsToReview.Count)"
if ($prsToReview.Count -gt 0) {
$prsToReview | ForEach-Object -ThrottleLimit $ReviewThrottleLimit -Parallel {
$pr = $_
$issueNum = $pr.IssueNumber
$prNum = $pr.PRNumber
$prReviewScript = $using:prReviewScript
$CLIType = $using:CLIType
$results = $using:results
try {
Write-Host "[PR #$prNum] Starting review workflow..." -ForegroundColor Cyan
& $prReviewScript -PRNumbers $prNum -CLIType $CLIType -Force 2>&1 | Out-Null
$results.ReviewSucceeded.Add(@{ IssueNumber = $issueNum; PRNumber = $prNum })
Write-Host "[PR #$prNum] ✓ Review completed" -ForegroundColor Green
}
catch {
$results.ReviewFailed.Add(@{ IssueNumber = $issueNum; PRNumber = $prNum; Error = $_.Exception.Message })
Write-Host "[PR #$prNum] ✗ Review failed: $($_.Exception.Message)" -ForegroundColor Red
}
}
}
Info "`nPhase 3 complete: $($results.ReviewSucceeded.Count) succeeded, $($results.ReviewFailed.Count) failed"
# Final Summary
$duration = (Get-Date) - $startTime
Info "`n" + ("=" * 80)
Info "FULL CYCLE COMPLETE"
Info ("=" * 80)
Info "Duration: $($duration.ToString('hh\:mm\:ss'))"
Info ""
Info "Issues processed: $($issuesToProcess.Count)"
Success "Fixes succeeded: $($results.FixSucceeded.Count)"
if ($results.FixFailed.Count -gt 0) {
Err "Fixes failed: $($results.FixFailed.Count)"
}
Success "PRs created: $($results.PRCreated.Count)"
if ($results.PRSkipped.Count -gt 0) {
Warn "PRs skipped: $($results.PRSkipped.Count) (already existed)"
}
if ($results.PRFailed.Count -gt 0) {
Err "PRs failed: $($results.PRFailed.Count)"
}
Success "Reviews completed: $($results.ReviewSucceeded.Count)"
if ($results.ReviewFailed.Count -gt 0) {
Err "Reviews failed: $($results.ReviewFailed.Count)"
}
Info ""
Info "Summary by issue:"
foreach ($issue in $issuesToProcess) {
$issueNum = $issue.IssueNumber
$prInfo = $results.PRCreated.ToArray() | Where-Object { $_.IssueNumber -eq $issueNum } | Select-Object -First 1
if (-not $prInfo) {
$prInfo = $results.PRSkipped.ToArray() | Where-Object { $_.IssueNumber -eq $issueNum } | Select-Object -First 1
}
if (-not $prInfo -and $existingPRs.ContainsKey($issueNum)) {
$prInfo = @{ PRNumber = $existingPRs[$issueNum].PRNumber }
}
$prNum = if ($prInfo) { "PR #$($prInfo.PRNumber)" } else { "No PR" }
$fixStatus = if ($results.FixSucceeded.ToArray() -contains $issueNum) { "" } elseif ($results.FixFailed.ToArray().IssueNumber -contains $issueNum) { "" } else { "-" }
$reviewStatus = if ($results.ReviewSucceeded.ToArray().IssueNumber -contains $issueNum -or $results.ReviewSucceeded.ToArray().PRNumber -contains $prInfo.PRNumber) { "" } else { "-" }
Info (" Issue #{0,-6} [{1}Fix] [{2}Review] -> {3}" -f $issueNum, $fixStatus, $reviewStatus, $prNum)
}
Info ("=" * 80)
return @{
FixSucceeded = $results.FixSucceeded.ToArray()
FixFailed = $results.FixFailed.ToArray()
PRCreated = $results.PRCreated.ToArray()
PRSkipped = $results.PRSkipped.ToArray()
PRFailed = $results.PRFailed.ToArray()
ReviewSucceeded = $results.ReviewSucceeded.ToArray()
ReviewFailed = $results.ReviewFailed.ToArray()
}
}
catch {
Err "Error: $($_.Exception.Message)"
exit 1
}
#endregion

21
.github/skills/pr-review/LICENSE.txt vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

121
.github/skills/pr-review/SKILL.md vendored Normal file
View File

@@ -0,0 +1,121 @@
---
name: pr-review
description: Comprehensive pull request review with multi-step analysis and comment posting. Use when asked to review a PR, analyze pull request changes, check PR for issues, post review comments, validate PR quality, run code review on a PR, or audit pull request. Generates 13 review step files covering functionality, security, performance, accessibility, and more.
license: Complete terms in LICENSE.txt
---
# PR Review Skill
Perform comprehensive pull request reviews with multi-step analysis covering functionality, security, performance, accessibility, localization, and more.
## Skill Contents
This skill is **self-contained** with all required resources:
```
.github/skills/pr-review/
├── SKILL.md # This file
├── LICENSE.txt # MIT License
├── scripts/
│ ├── Start-PRReviewWorkflow.ps1 # Main review script
│ ├── Get-GitHubPrFilePatch.ps1 # Fetch PR file diffs
│ ├── Get-GitHubRawFile.ps1 # Download repo files
│ ├── Get-PrIncrementalChanges.ps1 # Detect incremental changes
│ └── Test-IncrementalReview.ps1 # Test incremental detection
└── references/
├── review-pr.prompt.md # Full review prompt
└── fix-pr-active-comments.prompt.md # Comment fix prompt
```
## Output Directory
All generated artifacts are placed under `Generated Files/prReview/<pr-number>/` at the repository root (gitignored).
```
Generated Files/prReview/
└── <pr-number>/
├── 00-OVERVIEW.md # Summary with all findings
├── 01-functionality.md # Functional correctness
├── 02-compatibility.md # Breaking changes, versioning
├── 03-performance.md # Performance implications
├── 04-accessibility.md # A11y compliance
├── 05-security.md # Security concerns
├── 06-localization.md # L10n readiness
├── 07-globalization.md # G11n considerations
├── 08-extensibility.md # API/extension points
├── 09-solid-design.md # SOLID principles
├── 10-repo-patterns.md # PowerToys conventions
├── 11-docs-automation.md # Documentation coverage
├── 12-code-comments.md # Code comment quality
└── 13-copilot-guidance.md # (if applicable)
```
## When to Use This Skill
- Review a specific pull request
- Analyze PR changes for quality issues
- Post review comments on a PR
- Validate PR against PowerToys standards
- Run comprehensive code review
## Prerequisites
- GitHub CLI (`gh`) installed and authenticated
- PowerShell 7+ for running scripts
- GitHub MCP configured (for posting comments)
## Required Variables
⚠️ **Before starting**, confirm `{{PRNumber}}` with the user. If not provided, **ASK**: "What PR number should I review?"
| Variable | Description | Example |
|----------|-------------|---------|
| `{{PRNumber}}` | Pull request number to review | `45234` |
## Workflow
### Step 1: Run PR Review
Execute the review workflow (use paths relative to this skill folder):
```powershell
# From repo root
.github/skills/pr-review/scripts/Start-PRReviewWorkflow.ps1 -PRNumbers {{PRNumber}} -CLIType copilot
```
This will:
1. Optionally assign GitHub Copilot as reviewer
2. Fetch PR diff and changed files
3. Generate 13 review step files
4. Post findings as review comments
### Step 2: Review Output
Check the generated files at `Generated Files/prReview/{{PRNumber}}/`
## CLI Options
| Parameter | Description | Default |
|-----------|-------------|---------|
| `-PRNumbers` | PR number(s) to review | From worktrees |
| `-CLIType` | AI CLI to use: `copilot` or `claude` | `copilot` |
| `-MinSeverity` | Min severity to post: `high`, `medium`, `low`, `info` | `medium` |
| `-SkipAssign` | Skip assigning Copilot as reviewer | `false` |
| `-SkipReview` | Skip the review step | `false` |
| `-SkipFix` | Skip the fix step | `false` |
| `-MaxParallel` | Maximum parallel jobs | `3` |
| `-Force` | Skip confirmation prompts | `false` |
## AI Prompt References
For manual AI invocation, prompts are at:
- `references/review-pr.prompt.md` - Full review instructions
- `references/fix-pr-active-comments.prompt.md` - Comment fix instructions
## Troubleshooting
| Problem | Solution |
|---------|----------|
| PR not found | Verify PR number: `gh pr view {{PRNumber}}` |
| Review incomplete | Check `_copilot-review.log` for errors |
| Comments not posted | Ensure GitHub MCP is configured |

View File

@@ -0,0 +1,70 @@
---
description: 'Fix active pull request comments with scoped changes'
name: 'fix-pr-active-comments'
agent: 'agent'
argument-hint: 'PR number or active PR URL'
---
# Fix Active PR Comments
## Mission
Resolve active pull request comments by applying only simple fixes. For complex refactors, write a plan instead of changing code.
## Scope & Preconditions
- You must have an active pull request context or a provided PR number.
- Only implement simple changes. Do not implement large refactors.
- If required context is missing, request it and stop.
## Inputs
- Required: ${input:pr_number:PR number or URL}
- Optional: ${input:comment_scope:files or areas to focus on}
- Optional: ${input:fixing_guidelines:additional fixing guidelines from the user}
## Workflow
1. Locate all active (unresolved) PR review comments for the given PR.
2. For each comment, classify the change scope:
- Simple change: limited edits, localized fix, low risk, no broad redesign.
- Large refactor: multi-file redesign, architecture change, or risky behavior change.
3. For each large refactor request:
- Do not modify code.
- Write a planning document to Generated Files/prReview/${input:pr_number}/fixPlan/.
4. For each simple change request:
- Implement the fix with minimal edits.
- Run quick checks if needed.
- Commit and push the change.
5. For comments that seem invalid, unclear, or not applicable (even if simple):
- Do not change code.
- Add the item to a summary table in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md.
- Consult back to the end user in a friendly, polite tone.
6. Respond to each comment that you fixed:
- Reply in the active conversation.
- Use a polite or friendly tone.
- Keep the response under 200 words.
- Resolve the comment after replying.
## Output Expectations
- Simple fixes: code changes committed and pushed.
- Large refactors: a plan file saved to Generated Files/prReview/${input:pr_number}/fixPlan/.
- Invalid or unclear comments: captured in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md.
- Each fixed comment has a reply under 200 words and is resolved.
## Plan File Template
Use this template for each large refactor item:
# Fix Plan: <short title>
## Context
- Comment link:
- Impacted areas:
## Overview Table Template
Use this table in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md:
| Comment link | Summary | Reason not applied | Suggested follow-up |
| --- | --- | --- | --- |
| | | | |
## Quality Assurance
- Verify plan file path exists.
- Ensure no code changes were made for large refactor items.
- Confirm replies are under 200 words and comments are resolved.

View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"github-artifacts": {
"command": "cmd",
"args": ["/c", "for /f %i in ('git rev-parse --show-toplevel') do node %i/tools/mcp/github-artifacts/launch.js"],
"tools": ["*"]
}
}
}

View File

@@ -0,0 +1,198 @@
---
agent: 'agent'
description: 'Perform a comprehensive PR review with per-step Markdown and machine-readable outputs'
---
# Review Pull Request
**Goal**: Given `{{pr_number}}`, run a *one-topic-per-step* review. Write files to `Generated Files/prReview/{{pr_number}}/` (replace `{{pr_number}}` with the integer). Emit machinereadable blocks for a GitHub MCP to post review comments.
## PR selection
Resolve the target PR using these fallbacks in order:
1. Parse the invocation text for an explicit identifier (first integer following patterns such as a leading hash and digits or the text `PR:` followed by digits).
2. If no PR is found yet, locate the newest `Generated Files/prReview/_batch/batch-overview-*.md` file (highest timestamp in filename, fallback newest mtime) and take the first entry in its `## PRs` list whose review folder is missing `00-OVERVIEW.md` or contains `__error.flag`.
3. If the batch file has no pending PRs, query assignments with `gh pr list --assignee @me --state open --json number,updatedAt --limit 20` and pick the most recently updated PR that does not already have a completed review folder.
4. If still unknown, run `gh pr view --json number` in the current branch and use that result when it is unambiguous.
5. If every step above fails, prompt the user for a PR number before proceeding.
## Fetch PR data with `gh`
- `gh pr view {{pr_number}} --json number,baseRefName,headRefName,baseRefOid,headRefOid,changedFiles,files`
- `gh api repos/:owner/:repo/pulls/{{pr_number}}/files?per_page=250` # patches for line mapping
### Incremental review workflow
1. **Check for existing review**: Read `Generated Files/prReview/{{pr_number}}/00-OVERVIEW.md`
2. **Extract state**: Parse `Last reviewed SHA:` from review metadata section
3. **Detect changes**: Run `Get-PrIncrementalChanges.ps1 -PullRequestNumber {{pr_number}} -LastReviewedCommitSha {{sha}}`
4. **Analyze result**:
- `NeedFullReview: true` → Review all files in the PR
- `NeedFullReview: false` and `IsIncremental: true` → Review only files in `ChangedFiles` array
- `ChangedFiles` is empty → No changes, skip review (update iteration history with "No changes since last review")
5. **Apply smart filtering**: Use the file patterns in smart step filtering table to skip irrelevant steps
6. **Update metadata**: After completing review, save current `headRefOid` as `Last reviewed SHA:` in `00-OVERVIEW.md`
### Reusable PowerShell scripts
Scripts live in `.github/review-tools/` to avoid repeated manual approvals during PR reviews:
| Script | Usage |
| --- | --- |
| `.github/review-tools/Get-GitHubRawFile.ps1` | Download a repository file at a given ref, optionally with line numbers. |
| `.github/review-tools/Get-GitHubPrFilePatch.ps1` | Fetch the unified diff for a specific file within a pull request via `gh api`. |
| `.github/review-tools/Get-PrIncrementalChanges.ps1` | Compare last reviewed SHA with current PR head to identify incremental changes. Returns JSON with changed files, new commits, and whether full review is needed. |
| `.github/review-tools/Test-IncrementalReview.ps1` | Test helper to preview incremental review detection for a PR. Use before running full review to see what changed. |
Always prefer these scripts (or new ones added under `.github/review-tools/`) over raw `gh api` or similar shell commands so the review flow does not trigger interactive approval prompts.
## Output files
Folder: `Generated Files/prReview/{{pr_number}}/`
Files: `00-OVERVIEW.md`, `01-functionality.md`, `02-compatibility.md`, `03-performance.md`, `04-accessibility.md`, `05-security.md`, `06-localization.md`, `07-globalization.md`, `08-extensibility.md`, `09-solid-design.md`, `10-repo-patterns.md`, `11-docs-automation.md`, `12-code-comments.md`, `13-copilot-guidance.md` *(only if guidance md exists).*
- **Write-after-step rule:** Immediately after completing each TODO step, persist that step's markdown file before proceeding to the next. Generate `00-OVERVIEW.md` only after every step file has been refreshed for the current run.
## Iteration management
- Determine the current review iteration by reading `00-OVERVIEW.md` (look for `Review iteration:`). If missing, assume iteration `1`.
- Extract the last reviewed SHA from `00-OVERVIEW.md` (look for `Last reviewed SHA:` in the review metadata section). If missing, this is iteration 1.
- **Incremental review detection**:
1. Call `.github/review-tools/Get-PrIncrementalChanges.ps1 -PullRequestNumber {{pr_number}} -LastReviewedCommitSha {{last_sha}}` to get delta analysis.
2. Parse the JSON result to determine if incremental review is possible (`IsIncremental: true`, `NeedFullReview: false`).
3. If force-push detected or first review, proceed with full review of all changed files.
4. If incremental, review only the files listed in `ChangedFiles` array and apply smart step filtering (see below).
- Increment the iteration for each review run and propagate the new value to all step files and the overview.
- Preserve prior iteration notes by keeping/expanding an `## Iteration history` section in each markdown file, appending the newest summary under `### Iteration <N>`.
- Summaries should capture key deltas since the previous iteration so reruns can pick up context quickly.
- **After review completion**, update `Last reviewed SHA:` in `00-OVERVIEW.md` with the current `headRefOid` and update the timestamp.
### Smart step filtering (incremental reviews only)
When performing incremental review, skip steps that are irrelevant based on changed file types:
| File pattern | Required steps | Skippable steps |
| --- | --- | --- |
| `**/*.cs`, `**/*.cpp`, `**/*.h` | Functionality, Compatibility, Performance, Security, SOLID, Repo patterns, Code comments | (depends on files) |
| `**/*.resx`, `**/Resources/*.xaml` | Localization, Globalization | Most others |
| `**/*.md` (docs) | Docs & automation | Most others (unless copilot guidance) |
| `**/*copilot*.md`, `.github/prompts/*.md` | Copilot guidance, Docs & automation | Most others |
| `**/*.csproj`, `**/*.vcxproj`, `**/packages.config` | Compatibility, Security, Repo patterns | Localization, Globalization, Accessibility |
| `**/UI/**`, `**/*View.xaml` | Accessibility, Localization | Performance (unless perf-sensitive controls) |
**Default**: If uncertain or files span multiple categories, run all applicable steps. When in doubt, be conservative and review more rather than less.
## TODO steps (one concern each)
1) Functionality
2) Compatibility
3) Performance
4) Accessibility
5) Security
6) Localization
7) Globalization
8) Extensibility
9) SOLID principles
10) Repo patterns
11) Docs & automation coverage for the changes
12) Code comments
13) Copilot guidance (conditional): if changed folders contain `*copilot*.md` or `.github/prompts/*.md`, review diffs **against** that guidance and write `13-copilot-guidance.md` (omit if none).
## Per-step file template (use verbatim)
```md
# <STEP TITLE>
**PR:** (populate with PR identifier) — Base:<baseRefName> Head:<headRefName>
**Review iteration:** ITERATION
## Iteration history
- Maintain subsections titled `### Iteration N` in reverse chronological order (append the latest at the top) with 24 bullet highlights.
### Iteration ITERATION
- <Latest key point 1>
- <Latest key point 2>
## Checks executed
- List the concrete checks for *this step only* (510 bullets).
## Findings
(If none, write **None**. Defaults have one or more blocks:)
```mcp-review-comment
{"file":"relative/path.ext","start_line":123,"end_line":125,"severity":"high|medium|low|info","tags":["<step-slug>","pr-tag-here"],"related_files":["optional/other/file1"],"body":"Problem → Why it matters → Concrete fix. If spans multiple files, name them here."}
```
Use the second tag to encode the PR number.
```
## Overview file (`00-OVERVIEW.md`) template
```md
# PR Review Overview — (populate with PR identifier)
**Review iteration:** ITERATION
**Changed files:** <n> | **High severity issues:** <count>
## Review metadata
**Last reviewed SHA:** <headRefOid from gh pr view>
**Last review timestamp:** <ISO8601 timestamp>
**Review mode:** <Full|Incremental (N files changed since iteration X)>
**Base ref:** <baseRefName>
**Head ref:** <headRefName>
## Step results
Write lines like: `01 Functionality — <OK|Issues|Skipped> (see 01-functionality.md)` … through step 13.
Mark steps as "Skipped" when using incremental review smart filtering.
## Iteration history
- Maintain subsections titled `### Iteration N` mirroring the per-step convention with concise deltas and cross-links to the relevant step files.
- For incremental reviews, list the specific files that changed and which commits were added.
```
## Line numbers & multifile issues
- Map headside lines from `patch` hunks (`@@ -a,b +c,d @@` → new lines `+c..+c+d-1`).
- For crossfile issues: set the primary `"file"`, list others in `"related_files"`, and name them in `"body"`.
## Posting (for MCP)
- Parse all ```mcp-review-comment``` blocks across step files and post as PR review comments.
- If posting isnt available, still write all files.
## Constraint
Read/analyze only; don't modify code. Keep comments small, specific, and fixoriented.
**Testing**: Use `.github/review-tools/Test-IncrementalReview.ps1 -PullRequestNumber 42374` to preview incremental detection before running full review.
## Scratch cache for large PRs
Create a local scratch workspace to progressively summarize diffs and reload state across runs.
### Paths
- Root: `Generated Files/prReview/{{pr_number}}/__tmp/`
- Files:
- `index.jsonl` — append-only JSON Lines index of artifacts.
- `todo-queue.json` — pending items (files/chunks/steps).
- `rollup-<step>-v<N>.md` — iterative per-step aggregates.
- `file-<hash>.txt` — optional saved chunk text (when needed).
### JSON schema (per line in `index.jsonl`)
```json
{"type":"chunk|summary|issue|crosslink",
"path":"relative/file.ext","chunk_id":"f-12","step":"functionality|compatibility|...",
"base_sha":"...", "head_sha":"...", "range":[start,end], "version":1,
"notes":"short text or key:value map", "created_utc":"ISO8601"}
```
### Phases (stateful; resume-safe)
0. **Discover** PR + SHAs: `gh pr view <PR> --json baseRefName,headRefName,baseRefOid,headRefOid,files`.
1. **Chunk** each changed file (head): split into ~300600 LOC or ~4k chars; stable `chunk_id` = hash(path+start).
- Save `chunk` records. Optionally write `file-<hash>.txt` for expensive chunks.
2. **Summarize** per chunk: intent, APIs, risks per TODO step; emit `summary` records (≤600 tokens each).
3. **Issues**: convert findings to machine-readable blocks and emit `issue` records (later rendered to step MD).
4. **Rollups**: build/update `rollup-<step>-v<N>.md` from `summary`+`issue`. Keep prior versions.
5. **Finalize**: write per-step files + `00-OVERVIEW.md` from rollups. Post comments via MCP if available.
### Re-use & token limits
- Always **reload** `index.jsonl` first; skip chunks with same `head_sha` and `range`.
- **Incremental review optimization**: When `Get-PrIncrementalChanges.ps1` returns a subset of changed files, load only chunks from those files. Reuse existing chunks/summaries for unchanged files.
- Prefer re-summarizing only changed chunks; merge chunk summaries → file summaries → step rollups.
- When context is tight, load only the minimal chunk text (or its saved `file-<hash>.txt`) needed for a comment.
### Original vs diff
- Fetch base content when needed: prefer `git show <baseRefName>:<path>`; fallback `gh api repos/:owner/:repo/contents/<path>?ref=<base_sha>` (base64).
- Use patch hunks from `gh api .../pulls/<PR>/files` to compute **head** line numbers.
### Queue-driven loop
- Seed `todo-queue.json` with all changed files.
- Process: chunk → summarize → detect issues → roll up.
- Append to `index.jsonl` after each step; never rewrite previous lines (append-only).
### Hygiene
- `__tmp/` is implementation detail; do not include in final artifacts.
- It is safe to delete to force a clean pass; the next run rebuilds it.

View File

@@ -0,0 +1,79 @@
<#
.SYNOPSIS
Retrieves the unified diff patch for a specific file in a GitHub pull request.
.DESCRIPTION
This script fetches the patch content (unified diff format) for a specified file
within a pull request. It uses the GitHub CLI (gh) to query the GitHub API and
retrieve file change information.
.PARAMETER PullRequestNumber
The pull request number to query.
.PARAMETER FilePath
The relative path to the file in the repository (e.g., "src/modules/main.cpp").
.PARAMETER RepositoryOwner
The GitHub repository owner. Defaults to "microsoft".
.PARAMETER RepositoryName
The GitHub repository name. Defaults to "PowerToys".
.EXAMPLE
.\Get-GitHubPrFilePatch.ps1 -PullRequestNumber 42374 -FilePath "src/modules/cmdpal/main.cpp"
Retrieves the patch for main.cpp in PR #42374.
.EXAMPLE
.\Get-GitHubPrFilePatch.ps1 -PullRequestNumber 42374 -FilePath "README.md" -RepositoryOwner "myorg" -RepositoryName "myrepo"
Retrieves the patch from a different repository.
.NOTES
Requires GitHub CLI (gh) to be installed and authenticated.
Run 'gh auth login' if not already authenticated.
.LINK
https://cli.github.com/
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, HelpMessage = "Pull request number")]
[int]$PullRequestNumber,
[Parameter(Mandatory = $true, HelpMessage = "Relative path to the file in the repository")]
[string]$FilePath,
[Parameter(Mandatory = $false, HelpMessage = "Repository owner")]
[string]$RepositoryOwner = "microsoft",
[Parameter(Mandatory = $false, HelpMessage = "Repository name")]
[string]$RepositoryName = "PowerToys"
)
# Construct GitHub API path for pull request files
$apiPath = "repos/$RepositoryOwner/$RepositoryName/pulls/$PullRequestNumber/files?per_page=250"
# Query GitHub API to get all files in the pull request
try {
$pullRequestFiles = gh api $apiPath | ConvertFrom-Json
} catch {
Write-Error "Failed to query GitHub API for PR #$PullRequestNumber. Ensure gh CLI is authenticated. Details: $_"
exit 1
}
# Find the matching file in the pull request
$matchedFile = $pullRequestFiles | Where-Object { $_.filename -eq $FilePath }
if (-not $matchedFile) {
Write-Error "File '$FilePath' not found in PR #$PullRequestNumber."
exit 1
}
# Check if patch content exists
if (-not $matchedFile.patch) {
Write-Warning "File '$FilePath' has no patch content (possibly binary or too large)."
return
}
# Output the patch content
$matchedFile.patch

View File

@@ -0,0 +1,91 @@
<#
.SYNOPSIS
Downloads and displays the content of a file from a GitHub repository at a specific git reference.
.DESCRIPTION
This script fetches the raw content of a file from a GitHub repository using GitHub's raw content API.
It can optionally display line numbers and supports any valid git reference (branch, tag, or commit SHA).
.PARAMETER FilePath
The relative path to the file in the repository (e.g., "src/modules/main.cpp").
.PARAMETER GitReference
The git reference (branch name, tag, or commit SHA) to fetch the file from. Defaults to "main".
.PARAMETER RepositoryOwner
The GitHub repository owner. Defaults to "microsoft".
.PARAMETER RepositoryName
The GitHub repository name. Defaults to "PowerToys".
.PARAMETER ShowLineNumbers
When specified, displays line numbers before each line of content.
.PARAMETER StartLineNumber
The starting line number to use when ShowLineNumbers is enabled. Defaults to 1.
.EXAMPLE
.\Get-GitHubRawFile.ps1 -FilePath "README.md" -GitReference "main"
Downloads and displays the README.md file from the main branch.
.EXAMPLE
.\Get-GitHubRawFile.ps1 -FilePath "src/runner/main.cpp" -GitReference "dev/feature-branch" -ShowLineNumbers
Downloads main.cpp from a feature branch and displays it with line numbers.
.EXAMPLE
.\Get-GitHubRawFile.ps1 -FilePath "LICENSE" -GitReference "abc123def" -ShowLineNumbers -StartLineNumber 10
Downloads the LICENSE file from a specific commit and displays it with line numbers starting at 10.
.NOTES
Requires internet connectivity to access GitHub's raw content API.
Does not require GitHub CLI authentication for public repositories.
.LINK
https://docs.github.com/en/rest/repos/contents
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, HelpMessage = "Relative path to the file in the repository")]
[string]$FilePath,
[Parameter(Mandatory = $false, HelpMessage = "Git reference (branch, tag, or commit SHA)")]
[string]$GitReference = "main",
[Parameter(Mandatory = $false, HelpMessage = "Repository owner")]
[string]$RepositoryOwner = "microsoft",
[Parameter(Mandatory = $false, HelpMessage = "Repository name")]
[string]$RepositoryName = "PowerToys",
[Parameter(Mandatory = $false, HelpMessage = "Display line numbers before each line")]
[switch]$ShowLineNumbers,
[Parameter(Mandatory = $false, HelpMessage = "Starting line number for display")]
[int]$StartLineNumber = 1
)
# Construct the raw content URL
$rawContentUrl = "https://raw.githubusercontent.com/$RepositoryOwner/$RepositoryName/$GitReference/$FilePath"
# Fetch the file content from GitHub
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri $rawContentUrl
} catch {
Write-Error "Failed to fetch file from $rawContentUrl. Details: $_"
exit 1
}
# Split content into individual lines
$contentLines = $response.Content -split "`n"
# Display the content with or without line numbers
if ($ShowLineNumbers) {
$currentLineNumber = $StartLineNumber
foreach ($line in $contentLines) {
Write-Output ("{0:d4}: {1}" -f $currentLineNumber, $line)
$currentLineNumber++
}
} else {
$contentLines | ForEach-Object { Write-Output $_ }
}

View File

@@ -0,0 +1,173 @@
<#
.SYNOPSIS
Detects changes between the last reviewed commit and current head of a pull request.
.DESCRIPTION
This script compares a previously reviewed commit SHA with the current head of a pull request
to determine what has changed. It helps enable incremental reviews by identifying new commits
and modified files since the last review iteration.
The script handles several scenarios:
- First review (no previous SHA provided)
- No changes (current SHA matches last reviewed SHA)
- Force-push detected (last reviewed SHA no longer in history)
- Incremental changes (new commits added since last review)
.PARAMETER PullRequestNumber
The pull request number to analyze.
.PARAMETER LastReviewedCommitSha
The commit SHA that was last reviewed. If omitted, this is treated as a first review.
.PARAMETER RepositoryOwner
The GitHub repository owner. Defaults to "microsoft".
.PARAMETER RepositoryName
The GitHub repository name. Defaults to "PowerToys".
.OUTPUTS
JSON object containing:
- PullRequestNumber: The PR number being analyzed
- CurrentHeadSha: The current head commit SHA
- LastReviewedSha: The last reviewed commit SHA (if provided)
- BaseRefName: Base branch name
- HeadRefName: Head branch name
- IsIncremental: Boolean indicating if incremental review is possible
- NeedFullReview: Boolean indicating if a full review is required
- ChangedFiles: Array of files that changed (filename, status, additions, deletions)
- NewCommits: Array of commits added since last review (sha, message, author, date)
- Summary: Human-readable description of changes
.EXAMPLE
.\Get-PrIncrementalChanges.ps1 -PullRequestNumber 42374
Analyzes PR #42374 with no previous review (first review scenario).
.EXAMPLE
.\Get-PrIncrementalChanges.ps1 -PullRequestNumber 42374 -LastReviewedCommitSha "abc123def456"
Compares current PR state against the last reviewed commit to identify incremental changes.
.EXAMPLE
$changes = .\Get-PrIncrementalChanges.ps1 -PullRequestNumber 42374 -LastReviewedCommitSha "abc123" | ConvertFrom-Json
if ($changes.IsIncremental) { Write-Host "Can perform incremental review" }
Captures the output as a PowerShell object for further processing.
.NOTES
Requires GitHub CLI (gh) to be installed and authenticated.
Run 'gh auth login' if not already authenticated.
.LINK
https://cli.github.com/
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, HelpMessage = "Pull request number")]
[int]$PullRequestNumber,
[Parameter(Mandatory = $false, HelpMessage = "Commit SHA that was last reviewed")]
[string]$LastReviewedCommitSha,
[Parameter(Mandatory = $false, HelpMessage = "Repository owner")]
[string]$RepositoryOwner = "microsoft",
[Parameter(Mandatory = $false, HelpMessage = "Repository name")]
[string]$RepositoryName = "PowerToys"
)
# Fetch current pull request state from GitHub
try {
$pullRequestData = gh pr view $PullRequestNumber --json headRefOid,headRefName,baseRefName,baseRefOid | ConvertFrom-Json
} catch {
Write-Error "Failed to fetch PR #$PullRequestNumber details. Details: $_"
exit 1
}
$currentHeadSha = $pullRequestData.headRefOid
$baseRefName = $pullRequestData.baseRefName
$headRefName = $pullRequestData.headRefName
# Initialize result object
$analysisResult = @{
PullRequestNumber = $PullRequestNumber
CurrentHeadSha = $currentHeadSha
BaseRefName = $baseRefName
HeadRefName = $headRefName
LastReviewedSha = $LastReviewedCommitSha
IsIncremental = $false
NeedFullReview = $true
ChangedFiles = @()
NewCommits = @()
Summary = ""
}
# Scenario 1: First review (no previous SHA provided)
if ([string]::IsNullOrWhiteSpace($LastReviewedCommitSha)) {
$analysisResult.Summary = "Initial review - no previous iteration found"
$analysisResult.NeedFullReview = $true
return $analysisResult | ConvertTo-Json -Depth 10
}
# Scenario 2: No changes since last review
if ($currentHeadSha -eq $LastReviewedCommitSha) {
$analysisResult.Summary = "No changes since last review (SHA: $currentHeadSha)"
$analysisResult.NeedFullReview = $false
$analysisResult.IsIncremental = $true
return $analysisResult | ConvertTo-Json -Depth 10
}
# Scenario 3: Check for force-push (last reviewed SHA no longer exists in history)
try {
$null = gh api "repos/$RepositoryOwner/$RepositoryName/commits/$LastReviewedCommitSha" 2>&1
if ($LASTEXITCODE -ne 0) {
# SHA not found - likely force-push or branch rewrite
$analysisResult.Summary = "Force-push detected - last reviewed SHA $LastReviewedCommitSha no longer exists. Full review required."
$analysisResult.NeedFullReview = $true
return $analysisResult | ConvertTo-Json -Depth 10
}
} catch {
$analysisResult.Summary = "Cannot verify last reviewed SHA $LastReviewedCommitSha - assuming force-push. Full review required."
$analysisResult.NeedFullReview = $true
return $analysisResult | ConvertTo-Json -Depth 10
}
# Scenario 4: Get incremental changes between last reviewed SHA and current head
try {
$compareApiPath = "repos/$RepositoryOwner/$RepositoryName/compare/$LastReviewedCommitSha...$currentHeadSha"
$comparisonData = gh api $compareApiPath | ConvertFrom-Json
# Extract new commits information
$analysisResult.NewCommits = $comparisonData.commits | ForEach-Object {
@{
Sha = $_.sha.Substring(0, 7)
Message = $_.commit.message.Split("`n")[0] # First line only
Author = $_.commit.author.name
Date = $_.commit.author.date
}
}
# Extract changed files information
$analysisResult.ChangedFiles = $comparisonData.files | ForEach-Object {
@{
Filename = $_.filename
Status = $_.status # added, modified, removed, renamed
Additions = $_.additions
Deletions = $_.deletions
Changes = $_.changes
}
}
$fileCount = $analysisResult.ChangedFiles.Count
$commitCount = $analysisResult.NewCommits.Count
$analysisResult.IsIncremental = $true
$analysisResult.NeedFullReview = $false
$analysisResult.Summary = "Incremental review: $commitCount new commit(s), $fileCount file(s) changed since SHA $($LastReviewedCommitSha.Substring(0, 7))"
} catch {
Write-Error "Failed to compare commits. Details: $_"
$analysisResult.Summary = "Error comparing commits - defaulting to full review"
$analysisResult.NeedFullReview = $true
}
# Return the analysis result as JSON
return $analysisResult | ConvertTo-Json -Depth 10

View File

@@ -0,0 +1,18 @@
# IssueReviewLib.ps1 - Minimal helpers for PR review workflow
# Part of the PowerToys GitHub Copilot/Claude Code issue review system
# This is a trimmed version - pr-review only needs console helpers and repo root
#region Console Output Helpers
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Success { param([string]$Message) Write-Host $Message -ForegroundColor Green }
#endregion
#region Repository Helpers
function Get-RepoRoot {
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { throw 'Not inside a git repository.' }
return (Resolve-Path $root).Path
}
#endregion

View File

@@ -0,0 +1,541 @@
<#!
.SYNOPSIS
Review and fix PRs in parallel using GitHub Copilot and MCP.
.DESCRIPTION
For each PR (from worktrees or specified), runs in parallel:
1. Assigns GitHub Copilot as reviewer via GitHub MCP
2. Runs review-pr.prompt.md to generate review and post comments
3. Runs fix-pr-active-comments.prompt.md to fix issues
.PARAMETER PRNumbers
Array of PR numbers to process. If not specified, finds PRs from issue worktrees.
.PARAMETER SkipAssign
Skip assigning Copilot as reviewer.
.PARAMETER SkipReview
Skip the review step.
.PARAMETER SkipFix
Skip the fix step.
.PARAMETER MinSeverity
Minimum severity to post as PR comments: high, medium, low, info. Default: medium.
.PARAMETER MaxParallel
Maximum parallel jobs. Default: 3.
.PARAMETER DryRun
Show what would be done without executing.
.PARAMETER CLIType
AI CLI to use: copilot or claude. Default: copilot.
.EXAMPLE
# Process all PRs from issue worktrees
./Start-PRReviewWorkflow.ps1
.EXAMPLE
# Process specific PRs
./Start-PRReviewWorkflow.ps1 -PRNumbers 45234, 45235
.EXAMPLE
# Only review, don't fix
./Start-PRReviewWorkflow.ps1 -SkipFix
.EXAMPLE
# Dry run
./Start-PRReviewWorkflow.ps1 -DryRun
.NOTES
Prerequisites:
- GitHub CLI (gh) authenticated
- Copilot CLI installed
- GitHub MCP configured for posting comments
#>
[CmdletBinding()]
param(
[int[]]$PRNumbers,
[switch]$SkipAssign,
[switch]$SkipReview,
[switch]$SkipFix,
[ValidateSet('high', 'medium', 'low', 'info')]
[string]$MinSeverity = 'medium',
[int]$MaxParallel = 3,
[switch]$DryRun,
[ValidateSet('copilot', 'claude')]
[string]$CLIType = 'copilot',
[switch]$Force,
[switch]$Help
)
# Load libraries
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir/IssueReviewLib.ps1"
# Load worktree library
$repoRoot = Get-RepoRoot
$worktreeLib = Join-Path $repoRoot 'tools/build/WorktreeLib.ps1'
if (Test-Path $worktreeLib) {
. $worktreeLib
}
if ($Help) {
Get-Help $MyInvocation.MyCommand.Path -Full
return
}
function Get-PRsFromWorktrees {
<#
.SYNOPSIS
Get PR numbers from issue worktrees by checking for open PRs on each branch.
#>
$worktrees = Get-WorktreeEntries | Where-Object { $_.Branch -like 'issue/*' }
$prs = @()
foreach ($wt in $worktrees) {
$prInfo = gh pr list --head $wt.Branch --json number,url --state open 2>$null | ConvertFrom-Json
if ($prInfo -and $prInfo.Count -gt 0) {
$prs += @{
PRNumber = $prInfo[0].number
PRUrl = $prInfo[0].url
Branch = $wt.Branch
WorktreePath = $wt.Path
}
}
}
return $prs
}
function Invoke-AssignCopilotReviewer {
<#
.SYNOPSIS
Assign GitHub Copilot as a reviewer to the PR using GitHub MCP.
#>
param(
[Parameter(Mandatory)]
[int]$PRNumber,
[string]$CLIType = 'copilot',
[switch]$DryRun
)
if ($DryRun) {
Info " [DRY RUN] Would request Copilot review for PR #$PRNumber"
return $true
}
# Use a prompt that instructs Copilot to use GitHub MCP to assign Copilot as reviewer
$prompt = @"
Use the GitHub MCP to request a review from GitHub Copilot for PR #$PRNumber.
Steps:
1. Use the GitHub MCP tool to add "Copilot" as a reviewer to pull request #$PRNumber in the microsoft/PowerToys repository
2. This should add Copilot to the "Reviewers" section of the PR
If GitHub MCP is not available, report that and skip this step.
"@
# MCP config for github-artifacts tools (relative to repo root)
$mcpConfig = '@.github/skills/pr-review/references/mcp-config.json'
try {
Info " Requesting Copilot review via GitHub MCP..."
switch ($CLIType) {
'copilot' {
& copilot --additional-mcp-config $mcpConfig -p $prompt --yolo -s 2>&1 | Out-Null
}
'claude' {
& claude --print --dangerously-skip-permissions --prompt $prompt 2>&1 | Out-Null
}
}
return $true
}
catch {
Warn " Could not assign Copilot reviewer: $($_.Exception.Message)"
return $false
}
}
function Invoke-PRReview {
<#
.SYNOPSIS
Run review-pr.prompt.md using Copilot CLI.
#>
param(
[Parameter(Mandatory)]
[int]$PRNumber,
[string]$CLIType = 'copilot',
[string]$MinSeverity = 'medium',
[switch]$DryRun
)
# Simple prompt - let the prompt file define all the details
$prompt = @"
Follow exactly what at .github/prompts/review-pr.prompt.md to do with PR #$PRNumber.
Post findings with severity >= $MinSeverity as PR review comments via GitHub MCP.
"@
if ($DryRun) {
Info " [DRY RUN] Would run PR review for #$PRNumber"
return @{ Success = $true; ReviewPath = "Generated Files/prReview/$PRNumber" }
}
$reviewPath = Join-Path $repoRoot "Generated Files/prReview/$PRNumber"
# Ensure the review directory exists
if (-not (Test-Path $reviewPath)) {
New-Item -ItemType Directory -Path $reviewPath -Force | Out-Null
}
# MCP config for github-artifacts tools (relative to repo root)
$mcpConfig = '@.github/skills/pr-review/references/mcp-config.json'
Push-Location $repoRoot
try {
switch ($CLIType) {
'copilot' {
Info " Running Copilot review (this may take several minutes)..."
$output = & copilot --additional-mcp-config $mcpConfig -p $prompt --yolo 2>&1
# Log output for debugging
$logFile = Join-Path $reviewPath "_copilot-review.log"
$output | Out-File -FilePath $logFile -Force
}
'claude' {
Info " Running Claude review (this may take several minutes)..."
$output = & claude --print --dangerously-skip-permissions --prompt $prompt 2>&1
$logFile = Join-Path $reviewPath "_claude-review.log"
$output | Out-File -FilePath $logFile -Force
}
}
# Check if review files were created (at minimum, check for multiple step files)
$overviewPath = Join-Path $reviewPath '00-OVERVIEW.md'
$stepFiles = Get-ChildItem -Path $reviewPath -Filter "*.md" -ErrorAction SilentlyContinue
$stepCount = ($stepFiles | Where-Object { $_.Name -match '^\d{2}-' }).Count
if ($stepCount -ge 5) {
return @{ Success = $true; ReviewPath = $reviewPath; StepFilesCreated = $stepCount }
} elseif (Test-Path $overviewPath) {
Warn " Only overview created, step files may be incomplete ($stepCount step files)"
return @{ Success = $true; ReviewPath = $reviewPath; StepFilesCreated = $stepCount; Partial = $true }
} else {
return @{ Success = $false; Error = "Review files not created (found $stepCount step files)" }
}
}
catch {
return @{ Success = $false; Error = $_.Exception.Message }
}
finally {
Pop-Location
}
}
function Invoke-FixPRComments {
<#
.SYNOPSIS
Run fix-pr-active-comments.prompt.md to fix issues.
#>
param(
[Parameter(Mandatory)]
[int]$PRNumber,
[string]$WorktreePath,
[string]$CLIType = 'copilot',
[switch]$DryRun
)
# Simple prompt - let the prompt file define all the details
$prompt = "Follow .github/prompts/fix-pr-active-comments.prompt.md for PR #$PRNumber."
if ($DryRun) {
Info " [DRY RUN] Would fix PR comments for #$PRNumber"
return @{ Success = $true }
}
$workDir = if ($WorktreePath -and (Test-Path $WorktreePath)) { $WorktreePath } else { $repoRoot }
# MCP config for github-artifacts tools (relative to repo root)
$mcpConfig = '@.github/skills/pr-review/references/mcp-config.json'
Push-Location $workDir
try {
switch ($CLIType) {
'copilot' {
Info " Running Copilot to fix comments..."
$output = & copilot --additional-mcp-config $mcpConfig -p $prompt --yolo 2>&1
# Log output for debugging
$logPath = Join-Path $repoRoot "Generated Files/prReview/$PRNumber"
if (-not (Test-Path $logPath)) {
New-Item -ItemType Directory -Path $logPath -Force | Out-Null
}
$logFile = Join-Path $logPath "_copilot-fix.log"
$output | Out-File -FilePath $logFile -Force
}
'claude' {
Info " Running Claude to fix comments..."
$output = & claude --print --dangerously-skip-permissions --prompt $prompt 2>&1
$logPath = Join-Path $repoRoot "Generated Files/prReview/$PRNumber"
if (-not (Test-Path $logPath)) {
New-Item -ItemType Directory -Path $logPath -Force | Out-Null
}
$logFile = Join-Path $logPath "_claude-fix.log"
$output | Out-File -FilePath $logFile -Force
}
}
return @{ Success = $true }
}
catch {
return @{ Success = $false; Error = $_.Exception.Message }
}
finally {
Pop-Location
}
}
function Start-PRWorkflowJob {
<#
.SYNOPSIS
Process a single PR through the workflow.
#>
param(
[Parameter(Mandatory)]
[int]$PRNumber,
[string]$WorktreePath,
[string]$CLIType = 'copilot',
[string]$MinSeverity = 'medium',
[switch]$SkipAssign,
[switch]$SkipReview,
[switch]$SkipFix,
[switch]$DryRun
)
$result = @{
PRNumber = $PRNumber
AssignResult = $null
ReviewResult = $null
FixResult = $null
Success = $true
}
# Step 1: Assign Copilot as reviewer
if (-not $SkipAssign) {
Info " Step 1: Assigning Copilot reviewer..."
$result.AssignResult = Invoke-AssignCopilotReviewer -PRNumber $PRNumber -CLIType $CLIType -DryRun:$DryRun
if (-not $result.AssignResult) {
Warn " Assignment step had issues (continuing...)"
}
} else {
Info " Step 1: Skipped (assign)"
}
# Step 2: Run PR review
if (-not $SkipReview) {
Info " Step 2: Running PR review..."
$result.ReviewResult = Invoke-PRReview -PRNumber $PRNumber -CLIType $CLIType -MinSeverity $MinSeverity -DryRun:$DryRun
if (-not $result.ReviewResult.Success) {
Warn " Review step failed: $($result.ReviewResult.Error)"
$result.Success = $false
} else {
$stepInfo = if ($result.ReviewResult.StepFilesCreated) { " ($($result.ReviewResult.StepFilesCreated) step files)" } else { "" }
$partialInfo = if ($result.ReviewResult.Partial) { " [PARTIAL]" } else { "" }
Success " Review completed: $($result.ReviewResult.ReviewPath)$stepInfo$partialInfo"
}
} else {
Info " Step 2: Skipped (review)"
}
# Step 3: Fix PR comments
if (-not $SkipFix) {
Info " Step 3: Fixing PR comments..."
$result.FixResult = Invoke-FixPRComments -PRNumber $PRNumber -WorktreePath $WorktreePath -CLIType $CLIType -DryRun:$DryRun
if (-not $result.FixResult.Success) {
Warn " Fix step failed: $($result.FixResult.Error)"
$result.Success = $false
} else {
Success " Fix step completed"
}
} else {
Info " Step 3: Skipped (fix)"
}
return $result
}
#region Main Script
try {
Info "Repository root: $repoRoot"
Info "CLI type: $CLIType"
Info "Min severity for comments: $MinSeverity"
Info "Max parallel: $MaxParallel"
# Determine PRs to process
$prsToProcess = @()
if ($PRNumbers -and $PRNumbers.Count -gt 0) {
# Use specified PR numbers
foreach ($prNum in $PRNumbers) {
$prInfo = gh pr view $prNum --json number,url,headRefName 2>$null | ConvertFrom-Json
if ($prInfo) {
# Try to find matching worktree
$wt = Get-WorktreeEntries | Where-Object { $_.Branch -eq $prInfo.headRefName } | Select-Object -First 1
$prsToProcess += @{
PRNumber = $prInfo.number
PRUrl = $prInfo.url
Branch = $prInfo.headRefName
WorktreePath = if ($wt) { $wt.Path } else { $repoRoot }
}
} else {
Warn "PR #$prNum not found"
}
}
} else {
# Get PRs from worktrees
Info "`nFinding PRs from issue worktrees..."
$prsToProcess = Get-PRsFromWorktrees
}
if ($prsToProcess.Count -eq 0) {
Warn "No PRs found to process."
return
}
# Display PRs
Info "`nPRs to process:"
Info ("-" * 80)
foreach ($pr in $prsToProcess) {
Info (" #{0,-6} {1}" -f $pr.PRNumber, $pr.PRUrl)
}
Info ("-" * 80)
if ($DryRun) {
Warn "`nDry run mode - no changes will be made."
}
# Confirm
if (-not $Force -and -not $DryRun) {
$stepsDesc = @()
if (-not $SkipAssign) { $stepsDesc += "assign Copilot" }
if (-not $SkipReview) { $stepsDesc += "review" }
if (-not $SkipFix) { $stepsDesc += "fix comments" }
$confirm = Read-Host "`nProceed with $($prsToProcess.Count) PRs ($($stepsDesc -join ', '))? (y/N)"
if ($confirm -notmatch '^[yY]') {
Info "Cancelled."
return
}
}
# Process PRs (using jobs for parallelization)
$results = @{
Success = @()
Failed = @()
}
if ($MaxParallel -gt 1 -and $prsToProcess.Count -gt 1) {
# Parallel processing using PowerShell jobs
Info "`nStarting parallel processing (max $MaxParallel concurrent)..."
$jobs = @()
$prQueue = [System.Collections.Queue]::new($prsToProcess)
while ($prQueue.Count -gt 0 -or $jobs.Count -gt 0) {
# Start new jobs up to MaxParallel
while ($jobs.Count -lt $MaxParallel -and $prQueue.Count -gt 0) {
$pr = $prQueue.Dequeue()
Info "`n" + ("=" * 60)
Info "PROCESSING PR #$($pr.PRNumber)"
Info ("=" * 60)
# For simplicity, process sequentially within each PR but PRs in parallel
# Since copilot CLI might have issues with true parallel execution
$jobResult = Start-PRWorkflowJob `
-PRNumber $pr.PRNumber `
-WorktreePath $pr.WorktreePath `
-CLIType $CLIType `
-MinSeverity $MinSeverity `
-SkipAssign:$SkipAssign `
-SkipReview:$SkipReview `
-SkipFix:$SkipFix `
-DryRun:$DryRun
if ($jobResult.Success) {
$results.Success += $jobResult
Success "✓ PR #$($pr.PRNumber) workflow completed"
} else {
$results.Failed += $jobResult
Err "✗ PR #$($pr.PRNumber) workflow had failures"
}
}
}
} else {
# Sequential processing
foreach ($pr in $prsToProcess) {
Info "`n" + ("=" * 60)
Info "PROCESSING PR #$($pr.PRNumber)"
Info ("=" * 60)
$jobResult = Start-PRWorkflowJob `
-PRNumber $pr.PRNumber `
-WorktreePath $pr.WorktreePath `
-CLIType $CLIType `
-MinSeverity $MinSeverity `
-SkipAssign:$SkipAssign `
-SkipReview:$SkipReview `
-SkipFix:$SkipFix `
-DryRun:$DryRun
if ($jobResult.Success) {
$results.Success += $jobResult
Success "✓ PR #$($pr.PRNumber) workflow completed"
} else {
$results.Failed += $jobResult
Err "✗ PR #$($pr.PRNumber) workflow had failures"
}
}
}
# Summary
Info "`n" + ("=" * 80)
Info "PR REVIEW WORKFLOW COMPLETE"
Info ("=" * 80)
Info "Total PRs: $($prsToProcess.Count)"
if ($results.Success.Count -gt 0) {
Success "Succeeded: $($results.Success.Count)"
foreach ($r in $results.Success) {
Success " PR #$($r.PRNumber)"
}
}
if ($results.Failed.Count -gt 0) {
Err "Had issues: $($results.Failed.Count)"
foreach ($r in $results.Failed) {
Err " PR #$($r.PRNumber)"
}
}
Info "`nReview files location: Generated Files/prReview/<PR_NUMBER>/"
Info ("=" * 80)
return $results
}
catch {
Err "Error: $($_.Exception.Message)"
exit 1
}
#endregion

View File

@@ -0,0 +1,170 @@
<#
.SYNOPSIS
Tests and previews incremental review detection for a pull request.
.DESCRIPTION
This helper script validates the incremental review detection logic by analyzing an existing
PR review folder. It reads the last reviewed SHA from the overview file, compares it with
the current PR state, and displays detailed information about what has changed.
This is useful for:
- Testing the incremental review system before running a full review
- Understanding what changed since the last review iteration
- Verifying that review metadata was properly recorded
.PARAMETER PullRequestNumber
The pull request number to test incremental review detection for.
.PARAMETER RepositoryOwner
The GitHub repository owner. Defaults to "microsoft".
.PARAMETER RepositoryName
The GitHub repository name. Defaults to "PowerToys".
.OUTPUTS
Colored console output displaying:
- Current and last reviewed commit SHAs
- Whether incremental review is possible
- List of new commits since last review
- List of changed files with status indicators
- Recommended review strategy
.EXAMPLE
.\Test-IncrementalReview.ps1 -PullRequestNumber 42374
Tests incremental review detection for PR #42374.
.EXAMPLE
.\Test-IncrementalReview.ps1 -PullRequestNumber 42374 -RepositoryOwner "myorg" -RepositoryName "myrepo"
Tests incremental review for a PR in a different repository.
.NOTES
Requires GitHub CLI (gh) to be installed and authenticated.
Run 'gh auth login' if not already authenticated.
Prerequisites:
- PR review folder must exist at "Generated Files\prReview\{PRNumber}"
- 00-OVERVIEW.md must exist in the review folder
- For incremental detection, overview must contain "Last reviewed SHA" metadata
.LINK
https://cli.github.com/
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, HelpMessage = "Pull request number to test")]
[int]$PullRequestNumber,
[Parameter(Mandatory = $false, HelpMessage = "Repository owner")]
[string]$RepositoryOwner = "microsoft",
[Parameter(Mandatory = $false, HelpMessage = "Repository name")]
[string]$RepositoryName = "PowerToys"
)
# Resolve paths to review folder and overview file
$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$reviewFolderPath = Join-Path $repositoryRoot "Generated Files\prReview\$PullRequestNumber"
$overviewFilePath = Join-Path $reviewFolderPath "00-OVERVIEW.md"
Write-Host "=== Testing Incremental Review for PR #$PullRequestNumber ===" -ForegroundColor Cyan
Write-Host ""
# Check if review folder exists
if (-not (Test-Path $reviewFolderPath)) {
Write-Host "❌ Review folder not found: $reviewFolderPath" -ForegroundColor Red
Write-Host "This appears to be a new review (iteration 1)" -ForegroundColor Yellow
exit 0
}
# Check if overview file exists
if (-not (Test-Path $overviewFilePath)) {
Write-Host "❌ Overview file not found: $overviewFilePath" -ForegroundColor Red
Write-Host "This appears to be an incomplete review" -ForegroundColor Yellow
exit 0
}
# Read overview file and extract last reviewed SHA
Write-Host "📄 Reading overview file..." -ForegroundColor Green
$overviewFileContent = Get-Content $overviewFilePath -Raw
if ($overviewFileContent -match '\*\*Last reviewed SHA:\*\*\s+(\w+)') {
$lastReviewedSha = $Matches[1]
Write-Host "✅ Found last reviewed SHA: $lastReviewedSha" -ForegroundColor Green
} else {
Write-Host "⚠️ No 'Last reviewed SHA' found in overview - this may be an old format" -ForegroundColor Yellow
Write-Host "Proceeding without incremental detection (full review will be needed)" -ForegroundColor Yellow
exit 0
}
Write-Host ""
Write-Host "🔍 Running incremental change detection..." -ForegroundColor Cyan
# Call the incremental changes detection script
$incrementalChangesScriptPath = Join-Path $PSScriptRoot "Get-PrIncrementalChanges.ps1"
if (-not (Test-Path $incrementalChangesScriptPath)) {
Write-Host "❌ Script not found: $incrementalChangesScriptPath" -ForegroundColor Red
exit 1
}
try {
$analysisResult = & $incrementalChangesScriptPath `
-PullRequestNumber $PullRequestNumber `
-LastReviewedCommitSha $lastReviewedSha `
-RepositoryOwner $RepositoryOwner `
-RepositoryName $RepositoryName | ConvertFrom-Json
# Display analysis results
Write-Host ""
Write-Host "=== Incremental Review Analysis ===" -ForegroundColor Cyan
Write-Host "Current HEAD SHA: $($analysisResult.CurrentHeadSha)" -ForegroundColor White
Write-Host "Last reviewed SHA: $($analysisResult.LastReviewedSha)" -ForegroundColor White
Write-Host "Base branch: $($analysisResult.BaseRefName)" -ForegroundColor White
Write-Host "Head branch: $($analysisResult.HeadRefName)" -ForegroundColor White
Write-Host ""
Write-Host "Is incremental? $($analysisResult.IsIncremental)" -ForegroundColor $(if ($analysisResult.IsIncremental) { "Green" } else { "Yellow" })
Write-Host "Need full review? $($analysisResult.NeedFullReview)" -ForegroundColor $(if ($analysisResult.NeedFullReview) { "Yellow" } else { "Green" })
Write-Host ""
Write-Host "Summary: $($analysisResult.Summary)" -ForegroundColor Cyan
Write-Host ""
# Display new commits if any
if ($analysisResult.NewCommits -and $analysisResult.NewCommits.Count -gt 0) {
Write-Host "📝 New commits ($($analysisResult.NewCommits.Count)):" -ForegroundColor Green
foreach ($commit in $analysisResult.NewCommits) {
Write-Host " - $($commit.Sha): $($commit.Message)" -ForegroundColor Gray
}
Write-Host ""
}
# Display changed files if any
if ($analysisResult.ChangedFiles -and $analysisResult.ChangedFiles.Count -gt 0) {
Write-Host "📁 Changed files ($($analysisResult.ChangedFiles.Count)):" -ForegroundColor Green
foreach ($file in $analysisResult.ChangedFiles) {
$statusDisplayColor = switch ($file.Status) {
"added" { "Green" }
"removed" { "Red" }
"modified" { "Yellow" }
"renamed" { "Cyan" }
default { "White" }
}
Write-Host " - [$($file.Status)] $($file.Filename) (+$($file.Additions)/-$($file.Deletions))" -ForegroundColor $statusDisplayColor
}
Write-Host ""
}
# Suggest review strategy based on analysis
Write-Host "=== Recommended Review Strategy ===" -ForegroundColor Cyan
if ($analysisResult.NeedFullReview) {
Write-Host "🔄 Full review recommended" -ForegroundColor Yellow
} elseif ($analysisResult.IsIncremental -and ($analysisResult.ChangedFiles.Count -eq 0)) {
Write-Host "✅ No changes detected - no review needed" -ForegroundColor Green
} elseif ($analysisResult.IsIncremental) {
Write-Host "⚡ Incremental review possible - review only changed files" -ForegroundColor Green
Write-Host "💡 Consider applying smart step filtering based on file types" -ForegroundColor Cyan
}
} catch {
Write-Host "❌ Error running incremental change detection: $_" -ForegroundColor Red
exit 1
}

21
.github/skills/submit-pr/LICENSE.txt vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

126
.github/skills/submit-pr/SKILL.md vendored Normal file
View File

@@ -0,0 +1,126 @@
---
name: submit-pr
description: Commit changes and create pull requests for fixed issues. Use when asked to create a PR, submit changes, commit fixes, push changes for an issue, create pull request from worktree, or finalize issue fix. Generates AI-assisted commit messages and PR descriptions following PowerToys conventions.
license: Complete terms in LICENSE.txt
---
# Submit PR Skill
Commit changes from issue worktrees and create pull requests with AI-generated titles and descriptions following PowerToys conventions.
## Skill Contents
This skill is **self-contained** with all required resources:
```
.github/skills/submit-pr/
├── SKILL.md # This file
├── LICENSE.txt # MIT License
├── scripts/
│ └── Submit-IssueFixes.ps1 # Main submit script
└── references/
├── create-commit-title.prompt.md # Commit title rules
└── create-pr-summary.prompt.md # PR description template
```
## Output
PRs are created on GitHub with:
- Conventional commit title (e.g., `fix(fancyzones): resolve editor crash on multi-monitor`)
- Description following `.github/pull_request_template.md`
- Auto-linked to the original issue via `Fixes #{{IssueNumber}}`
## When to Use This Skill
- Create a PR for a fixed issue
- Commit and push changes from a worktree
- Submit changes after using `issue-fix` skill
- Generate PR title and description
- Finalize an issue fix with a pull request
## Prerequisites
- GitHub CLI (`gh`) installed and authenticated
- Changes made in an issue worktree (from `issue-fix` skill)
- PowerShell 7+ for running scripts
## Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `{{IssueNumber}}` | Issue number(s) to submit | `44044` or `44044, 32950` |
## Workflow
### Step 1: Verify Changes Exist
Check that the worktree has uncommitted or unpushed changes:
```powershell
# List issue worktrees
git worktree list | Select-String "issue/"
# Check status in a worktree
cd Q:/PowerToys-xxxx
git status
```
### Step 2: Submit PR
Execute the submit script (use paths relative to this skill folder):
```powershell
# From repo root
.github/skills/submit-pr/scripts/Submit-IssueFixes.ps1 -IssueNumbers {{IssueNumber}} -CLIType copilot
```
This will:
1. Generate a commit title using AI (following conventional commits)
2. Stage and commit all changes
3. Push the branch to origin
4. Generate a PR description using AI
5. Create the PR on GitHub
### Step 3: Review Created PR
The script outputs the PR URL. Review it on GitHub.
## CLI Options
| Parameter | Description | Default |
|-----------|-------------|---------|
| `-IssueNumbers` | Issue number(s) to submit | All worktrees |
| `-CLIType` | AI CLI to use: `copilot`, `claude`, or `manual` | `copilot` |
| `-TargetBranch` | Base branch for PR | `main` |
| `-Draft` | Create as draft PR | `false` |
| `-Force` | Skip confirmation prompts | `false` |
| `-DryRun` | Show what would be done | `false` |
## PR Title Format
Titles follow conventional commits (see `references/create-commit-title.prompt.md`):
```
<type>(<scope>): <description>
```
| Type | When to use |
|------|-------------|
| `fix` | Bug fixes |
| `feat` | New features |
| `docs` | Documentation only |
| `refactor` | Code restructuring |
## AI Prompt References
For manual AI invocation, prompts are at:
- `references/create-commit-title.prompt.md` - Commit title generation
- `references/create-pr-summary.prompt.md` - PR description generation
## Troubleshooting
| Problem | Solution |
|---------|----------|
| No changes to commit | Verify fix was applied, check `git status` |
| PR already exists | Script will skip and report existing PR URL |
| Push rejected | Pull latest changes or force push with `--force-with-lease` |

View File

@@ -0,0 +1,49 @@
---
agent: 'agent'
description: 'Generate an 80-character git commit title for the local diff'
---
# Generate Commit Title
## Purpose
Provide a single-line, ready-to-paste git commit title (<= 80 characters) that reflects the most important local changes since `HEAD`.
## Input to collect
- Run exactly one command to view the local diff:
```@terminal
git diff HEAD
```
## How to decide the title
1. From the diff, find the dominant area (e.g., `src/modules/*`, `doc/devdocs/**`) and the change type (bug fix, docs update, config tweak).
2. Draft an imperative, plain-ASCII title that:
- Mentions the primary component when obvious (e.g., `FancyZones:` or `Docs:`)
- Stays within 80 characters and has no trailing punctuation
## Final output
- Reply with only the commit title on a single line—no extra text.
## PR title convention (when asked)
Use Conventional Commits style:
`<type>(<scope>): <summary>`
**Allowed types**
- feat, fix, docs, refactor, perf, test, build, ci, chore
**Scope rules**
- Use a short, PowerToys-focused scope (one word preferred). Common scopes:
- Core: `runner`, `settings-ui`, `common`, `docs`, `build`, `ci`, `installer`, `gpo`, `dsc`
- Modules: `fancyzones`, `powerrename`, `awake`, `colorpicker`, `imageresizer`, `keyboardmanager`, `mouseutils`, `peek`, `hosts`, `file-locksmith`, `screen-ruler`, `text-extractor`, `cropandlock`, `paste`, `powerlauncher`
- If unclear, pick the closest module or subsystem; omit only if unavoidable
**Summary rules**
- Imperative, present tense (“add”, “update”, “remove”, “fix”)
- Keep it <= 72 characters when possible; be specific, avoid “misc changes”
**Examples**
- `feat(fancyzones): add canvas template duplication`
- `fix(mouseutils): guard crosshair toggle when dpi info missing`
- `docs(runner): document tray icon states`
- `build(installer): align wix v5 suffix flag`
- `ci(ci): cache pipeline artifacts for x64`

View File

@@ -0,0 +1,24 @@
---
agent: 'agent'
description: 'Generate a PowerToys-ready pull request description from the local diff'
---
# Generate PR Summary
**Goal:** Produce a ready-to-paste PR title and description that follows PowerToys conventions by comparing the current branch against a user-selected target branch.
**Repo guardrails:**
- Treat `.github/pull_request_template.md` as the single source of truth; load it at runtime instead of embedding hardcoded content in this prompt.
- Preserve section order from the template but only surface checklist lines that are relevant for the detected changes, filling them with `[x]`/`[ ]` as appropriate.
- Cite touched paths with inline backticks, matching the guidance in `.github/copilot-instructions.md`.
- Call out test coverage explicitly: list automated tests run (unit/UI) or state why they are not applicable.
**Workflow:**
1. Determine the target branch from user context; default to `main` when no branch is supplied.
2. Run `git status --short` once to surface uncommitted files that may influence the summary.
3. Run `git diff <target-branch>...HEAD` a single time to review the detailed changes. Only when confidence stays low dig deeper with focused calls such as `git diff <target-branch>...HEAD -- <path>`.
4. From the diff, capture impacted areas, key file changes, behavioral risks, migrations, and noteworthy edge cases.
5. Confirm validation: list tests executed with results or state why tests were skipped in line with repo guidance.
6. Load `.github/pull_request_template.md`, mirror its section order, and populate it with the gathered facts. Include only relevant checklist entries, marking them `[x]/[ ]` and noting any intentional omissions as "N/A".
7. Present the filled template inside a fenced ```markdown code block with no extra commentary so it is ready to paste into a PR, clearly flagging any placeholders that still need user input.
8. Prepend the PR title above the filled template, applying the Conventional Commit type/scope rules from `.github/prompts/create-commit-title.prompt.md`; pick the dominant component from the diff and keep the title concise and imperative.

View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"github-artifacts": {
"command": "cmd",
"args": ["/c", "for /f %i in ('git rev-parse --show-toplevel') do node %i/tools/mcp/github-artifacts/launch.js"],
"tools": ["*"]
}
}
}

View File

@@ -0,0 +1,18 @@
# IssueReviewLib.ps1 - Minimal helpers for PR submission workflow
# Part of the PowerToys GitHub Copilot/Claude Code issue review system
# This is a trimmed version - submit-pr only needs console helpers and repo root
#region Console Output Helpers
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Success { param([string]$Message) Write-Host $Message -ForegroundColor Green }
#endregion
#region Repository Helpers
function Get-RepoRoot {
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { throw 'Not inside a git repository.' }
return (Resolve-Path $root).Path
}
#endregion

View File

@@ -0,0 +1,559 @@
<#!
.SYNOPSIS
Commit and create PRs for completed issue fixes in worktrees.
.DESCRIPTION
For each specified issue (or all issue worktrees), commits changes using AI-generated
commit messages and creates PRs with AI-generated summaries, linking to the original issue.
.PARAMETER IssueNumbers
Array of issue numbers to submit. If not specified, processes all issue/* worktrees.
.PARAMETER DryRun
Show what would be done without actually committing or creating PRs.
.PARAMETER SkipCommit
Skip the commit step (assume changes are already committed).
.PARAMETER SkipPush
Skip pushing to remote (useful for testing).
.PARAMETER TargetBranch
Target branch for the PR. Default: main.
.PARAMETER CLIType
AI CLI to use for generating messages: copilot, claude, or manual. Default: copilot.
.PARAMETER Draft
Create PRs as drafts.
.EXAMPLE
# Submit all issue worktrees
./Submit-IssueFixes.ps1
.EXAMPLE
# Submit specific issues
./Submit-IssueFixes.ps1 -IssueNumbers 44044, 44480
.EXAMPLE
# Dry run to see what would happen
./Submit-IssueFixes.ps1 -DryRun
.EXAMPLE
# Create draft PRs
./Submit-IssueFixes.ps1 -Draft
.NOTES
Prerequisites:
- Worktrees created by Start-IssueAutoFix.ps1
- Changes made in the worktrees
- GitHub CLI (gh) authenticated
- Copilot CLI or Claude Code CLI
#>
[CmdletBinding()]
param(
[int[]]$IssueNumbers,
[switch]$DryRun,
[switch]$SkipCommit,
[switch]$SkipPush,
[string]$TargetBranch = 'main',
[ValidateSet('copilot', 'claude', 'manual')]
[string]$CLIType = 'copilot',
[switch]$Draft,
[switch]$Force,
[switch]$Help
)
# Load libraries
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir/IssueReviewLib.ps1"
# Load worktree library
$repoRoot = Get-RepoRoot
$worktreeLib = Join-Path $repoRoot 'tools/build/WorktreeLib.ps1'
if (Test-Path $worktreeLib) {
. $worktreeLib
}
if ($Help) {
Get-Help $MyInvocation.MyCommand.Path -Full
return
}
function Get-AIGeneratedCommitTitle {
<#
.SYNOPSIS
Generate commit title using AI CLI with create-commit-title prompt.
#>
param(
[Parameter(Mandatory)]
[string]$WorktreePath,
[string]$CLIType = 'copilot'
)
$promptFile = Join-Path $repoRoot '.github/prompts/create-commit-title.prompt.md'
if (-not (Test-Path $promptFile)) {
throw "Prompt file not found: $promptFile"
}
$prompt = "Follow the instructions in .github/prompts/create-commit-title.prompt.md to generate a commit title for the current changes. Output ONLY the commit title, nothing else."
# MCP config for github-artifacts tools (relative to repo root)
$mcpConfig = '@.github/skills/submit-pr/references/mcp-config.json'
Push-Location $WorktreePath
try {
switch ($CLIType) {
'copilot' {
$result = & copilot --additional-mcp-config $mcpConfig -p $prompt --yolo -s 2>&1
# Extract just the title line (last non-empty line that looks like a title)
$lines = $result -split "`n" | Where-Object { $_.Trim() -and $_ -notmatch '^\s*```' -and $_ -notmatch '^\s*#' }
$title = $lines | Select-Object -Last 1
return $title.Trim()
}
'claude' {
$result = & claude --print --dangerously-skip-permissions --prompt $prompt 2>&1
$lines = $result -split "`n" | Where-Object { $_.Trim() -and $_ -notmatch '^\s*```' }
$title = $lines | Select-Object -Last 1
return $title.Trim()
}
'manual' {
# Show diff and ask user for title
git diff HEAD --stat
return Read-Host "Enter commit title"
}
}
} finally {
Pop-Location
}
}
function Get-AIGeneratedPRSummary {
<#
.SYNOPSIS
Generate PR summary using AI CLI with create-pr-summary prompt.
#>
param(
[Parameter(Mandatory)]
[string]$WorktreePath,
[Parameter(Mandatory)]
[int]$IssueNumber,
[string]$TargetBranch = 'main',
[string]$CLIType = 'copilot'
)
$prompt = @"
Follow the instructions in .github/prompts/create-pr-summary.prompt.md to generate a PR summary.
Target branch: $TargetBranch
This PR fixes issue #$IssueNumber.
IMPORTANT:
1. Output the PR title on the first line
2. Then output the PR body in markdown format
3. Make sure to include "Fixes #$IssueNumber" in the body to auto-link the issue
"@
# MCP config for github-artifacts tools (relative to repo root)
$mcpConfig = '@.github/skills/submit-pr/references/mcp-config.json'
Push-Location $WorktreePath
try {
switch ($CLIType) {
'copilot' {
$result = & copilot --additional-mcp-config $mcpConfig -p $prompt --yolo -s 2>&1
return $result -join "`n"
}
'claude' {
$result = & claude --print --dangerously-skip-permissions --prompt $prompt 2>&1
return $result -join "`n"
}
'manual' {
git diff "$TargetBranch...HEAD" --stat
$title = Read-Host "Enter PR title"
$body = Read-Host "Enter PR body (or press Enter for default)"
if (-not $body) {
$body = "Fixes #$IssueNumber"
}
return "$title`n`n$body"
}
}
} finally {
Pop-Location
}
}
function Parse-PRContent {
<#
.SYNOPSIS
Parse AI output to extract PR title and body.
Expected format:
Line 1: feat(scope): title text
Line 2+: ```markdown
## Summary...
```
#>
param(
[Parameter(Mandatory)]
[string]$Content,
[int]$IssueNumber
)
$lines = $Content -split "`n"
# Title is the FIRST line that looks like a conventional commit
# Body is the content INSIDE the ```markdown ... ``` block
$title = $null
$body = $null
# Find title - first line matching conventional commit format
foreach ($line in $lines) {
$trimmed = $line.Trim()
if ($trimmed -match '^(feat|fix|docs|refactor|perf|test|build|ci|chore)(\([^)]+\))?:') {
$title = $trimmed -replace '^#+\s*', ''
break
}
}
# Fallback title
if (-not $title) {
$title = "fix: address issue #$IssueNumber"
}
# Extract body from markdown code block
$fullContent = $Content
if ($fullContent -match '```markdown\r?\n([\s\S]*?)\r?\n```') {
$body = $Matches[1].Trim()
} else {
# No markdown block - use everything after the title line
$titleIndex = [array]::IndexOf($lines, ($lines | Where-Object { $_.Trim() -eq $title } | Select-Object -First 1))
if ($titleIndex -ge 0 -and $titleIndex -lt $lines.Count - 1) {
$body = ($lines[($titleIndex + 1)..($lines.Count - 1)] -join "`n").Trim()
# Clean up any remaining code fences
$body = $body -replace '^```\w*\r?\n', '' -replace '\r?\n```\s*$', ''
} else {
$body = ""
}
}
# Ensure issue link is present
if ($body -notmatch "Fixes\s*#$IssueNumber" -and $body -notmatch "Closes\s*#$IssueNumber" -and $body -notmatch "Resolves\s*#$IssueNumber") {
$body = "$body`n`nFixes #$IssueNumber"
}
return @{
Title = $title
Body = $body
}
}
function Submit-IssueFix {
<#
.SYNOPSIS
Commit changes, push, and create PR for a single issue.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$WorktreePath,
[Parameter(Mandatory)]
[string]$Branch,
[string]$TargetBranch = 'main',
[string]$CLIType = 'copilot',
[switch]$DryRun,
[switch]$SkipCommit,
[switch]$SkipPush,
[switch]$Draft
)
Push-Location $WorktreePath
try {
# Check for changes
$status = git status --porcelain
$hasUncommitted = $status.Count -gt 0
# Check for commits ahead of target
git fetch origin $TargetBranch 2>$null
$commitsAhead = git rev-list --count "origin/$TargetBranch..$Branch" 2>$null
if (-not $commitsAhead) { $commitsAhead = 0 }
Info "Issue #$IssueNumber in $WorktreePath"
Info " Branch: $Branch"
Info " Uncommitted changes: $hasUncommitted"
Info " Commits ahead of $TargetBranch`: $commitsAhead"
if (-not $hasUncommitted -and $commitsAhead -eq 0) {
Warn " No changes to submit for issue #$IssueNumber"
return @{ IssueNumber = $IssueNumber; Status = 'NoChanges' }
}
# Step 1: Commit if there are uncommitted changes
if ($hasUncommitted -and -not $SkipCommit) {
Info " Generating commit title..."
if ($DryRun) {
Info " [DRY RUN] Would generate commit title and commit changes"
} else {
$commitTitle = Get-AIGeneratedCommitTitle -WorktreePath $WorktreePath -CLIType $CLIType
if (-not $commitTitle) {
throw "Failed to generate commit title"
}
Info " Commit title: $commitTitle"
# Stage all changes and commit
git add -A
git commit -m $commitTitle
if ($LASTEXITCODE -ne 0) {
throw "Git commit failed"
}
Success " ✓ Changes committed"
}
}
# Step 2: Push to remote
if (-not $SkipPush) {
if ($DryRun) {
Info " [DRY RUN] Would push branch $Branch to origin"
} else {
Info " Pushing to origin..."
git push -u origin $Branch 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
# Try force push if normal push fails (branch might have been reset)
Warn " Normal push failed, trying force push..."
git push -u origin $Branch --force-with-lease 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Git push failed"
}
}
Success " ✓ Pushed to origin"
}
}
# Step 3: Create PR
Info " Generating PR summary..."
if ($DryRun) {
Info " [DRY RUN] Would generate PR summary and create PR"
Info " [DRY RUN] PR would link to issue #$IssueNumber"
return @{ IssueNumber = $IssueNumber; Status = 'DryRun' }
}
# Check if PR already exists
$existingPR = gh pr list --head $Branch --json number,url 2>$null | ConvertFrom-Json
if ($existingPR -and $existingPR.Count -gt 0) {
Warn " PR already exists: $($existingPR[0].url)"
return @{ IssueNumber = $IssueNumber; Status = 'PRExists'; PRUrl = $existingPR[0].url }
}
$prContent = Get-AIGeneratedPRSummary -WorktreePath $WorktreePath -IssueNumber $IssueNumber -TargetBranch $TargetBranch -CLIType $CLIType
$parsed = Parse-PRContent -Content $prContent -IssueNumber $IssueNumber
if (-not $parsed.Title) {
throw "Failed to generate PR title"
}
Info " PR Title: $($parsed.Title)"
# Create PR using gh CLI
$ghArgs = @(
'pr', 'create',
'--base', $TargetBranch,
'--head', $Branch,
'--title', $parsed.Title,
'--body', $parsed.Body
)
if ($Draft) {
$ghArgs += '--draft'
}
$prResult = & gh @ghArgs 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to create PR: $prResult"
}
# Extract PR URL from result
$prUrl = $prResult | Select-String -Pattern 'https://github.com/[^\s]+' | ForEach-Object { $_.Matches[0].Value }
Success " ✓ PR created: $prUrl"
return @{
IssueNumber = $IssueNumber
Status = 'Success'
PRUrl = $prUrl
CommitTitle = $commitTitle
PRTitle = $parsed.Title
}
}
catch {
Err " ✗ Failed: $($_.Exception.Message)"
return @{
IssueNumber = $IssueNumber
Status = 'Failed'
Error = $_.Exception.Message
}
}
finally {
Pop-Location
}
}
#region Main Script
try {
Info "Repository root: $repoRoot"
Info "Target branch: $TargetBranch"
Info "CLI type: $CLIType"
# Get all issue worktrees
$allWorktrees = Get-WorktreeEntries | Where-Object { $_.Branch -like 'issue/*' }
if ($allWorktrees.Count -eq 0) {
Warn "No issue worktrees found. Run Start-IssueAutoFix.ps1 first."
return
}
# Filter to specified issues if provided
$worktreesToProcess = @()
if ($IssueNumbers -and $IssueNumbers.Count -gt 0) {
foreach ($issueNum in $IssueNumbers) {
$wt = $allWorktrees | Where-Object { $_.Branch -match "issue/$issueNum\b" }
if ($wt) {
$worktreesToProcess += $wt
} else {
Warn "No worktree found for issue #$issueNum"
}
}
} else {
$worktreesToProcess = $allWorktrees
}
if ($worktreesToProcess.Count -eq 0) {
Warn "No worktrees to process."
return
}
# Display worktrees to process
Info "`nWorktrees to submit:"
Info ("-" * 80)
foreach ($wt in $worktreesToProcess) {
# Extract issue number from branch name
if ($wt.Branch -match 'issue/(\d+)') {
$issueNum = $Matches[1]
Info " #$issueNum -> $($wt.Path) [$($wt.Branch)]"
}
}
Info ("-" * 80)
if ($DryRun) {
Warn "`nDry run mode - no changes will be made."
}
# Confirm before proceeding
if (-not $Force -and -not $DryRun) {
$confirm = Read-Host "`nProceed with submitting $($worktreesToProcess.Count) fixes? (y/N)"
if ($confirm -notmatch '^[yY]') {
Info "Cancelled."
return
}
}
# Process each worktree
$results = @{
Success = @()
Failed = @()
NoChanges = @()
PRExists = @()
DryRun = @()
}
foreach ($wt in $worktreesToProcess) {
if ($wt.Branch -match 'issue/(\d+)') {
$issueNum = [int]$Matches[1]
Info "`n" + ("=" * 60)
Info "SUBMITTING ISSUE #$issueNum"
Info ("=" * 60)
$result = Submit-IssueFix `
-IssueNumber $issueNum `
-WorktreePath $wt.Path `
-Branch $wt.Branch `
-TargetBranch $TargetBranch `
-CLIType $CLIType `
-DryRun:$DryRun `
-SkipCommit:$SkipCommit `
-SkipPush:$SkipPush `
-Draft:$Draft
switch ($result.Status) {
'Success' { $results.Success += $result }
'Failed' { $results.Failed += $result }
'NoChanges' { $results.NoChanges += $result }
'PRExists' { $results.PRExists += $result }
'DryRun' { $results.DryRun += $result }
}
}
}
# Summary
Info "`n" + ("=" * 80)
Info "SUBMISSION COMPLETE"
Info ("=" * 80)
Info "Total worktrees: $($worktreesToProcess.Count)"
if ($results.Success.Count -gt 0) {
Success "PRs created: $($results.Success.Count)"
foreach ($r in $results.Success) {
Success " #$($r.IssueNumber): $($r.PRUrl)"
}
}
if ($results.PRExists.Count -gt 0) {
Warn "PRs already exist: $($results.PRExists.Count)"
foreach ($r in $results.PRExists) {
Warn " #$($r.IssueNumber): $($r.PRUrl)"
}
}
if ($results.NoChanges.Count -gt 0) {
Warn "No changes: $($results.NoChanges.Count)"
Warn " Issues: $($results.NoChanges.IssueNumber -join ', ')"
}
if ($results.Failed.Count -gt 0) {
Err "Failed: $($results.Failed.Count)"
foreach ($r in $results.Failed) {
Err " #$($r.IssueNumber): $($r.Error)"
}
}
if ($results.DryRun.Count -gt 0) {
Info "Dry run: $($results.DryRun.Count)"
}
Info ("=" * 80)
return $results
}
catch {
Err "Error: $($_.Exception.Message)"
exit 1
}
#endregion

View File

@@ -300,6 +300,10 @@
</Project>
</Folder>
<Folder Name="/modules/CommandPalette/Tests/">
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Microsoft.CmdPal.Core.Common.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
@@ -356,6 +360,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj" Id="2eca18b7-33b7-4829-88f1-439b20fd60f6">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/CommandPalette/UI/">
<Project Path="src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj">

View File

@@ -0,0 +1,311 @@
# 🧭 Creating a new PowerToy: end-to-end developer guide
First of all, thank you for wanting to contribute to PowerToys. The work we do would not be possible without the support of community supporters like you.
This guide documents the process of building a new PowerToys utility from scratch, including architecture decisions, integration steps, and common pitfalls.
---
## 1. Overview and prerequisites
A PowerToy module is a self-contained utility integrated into the PowerToys ecosystem. It can be UI-based, service-based, or both.
### Requirements
- [Visual Studio 2026](https://visualstudio.microsoft.com/downloads/) and the following workloads/individual components:
- Desktop Development with C++
- WinUI application development
- .NET desktop development
- Windows 10 SDK (10.0.22621.0)
- Windows 11 SDK (10.0.26100.3916)
- .NET 8 SDK
- Fork the [PowerToys repository](https://github.com/microsoft/PowerToys/tree/main) locally
- [Validate that you are able to build and run](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/development/debugging.md) `PowerToys.slnx`.
Optional:
- [WiX v5 toolset](https://github.com/microsoft/PowerToys/tree/main) for the installer
> [!NOTE]
> To ensure all the correct VS Workloads are installed, use [the WinGet configuration files](https://github.com/microsoft/PowerToys/tree/e13d6a78aafbcf32a4bb5f8581d041e1d057c3f1/.config) in the project repository. (Use the one that matches your VS distribution. ie: VS Community would use `configuration.winget`)
### Folder structure
```
src/
modules/
your_module/
YourModule.sln
YourModuleInterface/
YourModuleUI/ (if needed)
YourModuleService/ (if needed)
```
---
## 2. Design and planning
### Decide the type of module
Think about how your module works and which existing modules behave similarly. You are going to want to think about the UI needed for the application, the lifecycle, whether it is a service that is always running or event based. Below are some basic scenarios with some modules to explore. You can write your application in C++ or C#.
- **UI-only:** e.g., ColorPicker
- **Background service:** e.g., LightSwitch, Awake
- **Hybrid (UI + background logic):** e.g., ShortcutGuide
- **C++/C# interop:** e.g., PowerRename
### Write your module interface
Begin by setting up the [PowerToy module template project](https://github.com/microsoft/PowerToys/tree/main/tools/project_template). This will generate boilerplate for you to begin your new module. Below are the key headers in the Module Interface (`dllmain.cpp`) and an explanation of their purpose:
1. This is where module settings are defined. These can be anything from strings, bools, ints, and even custom Enums.
```c++
struct ModuleSettings {};
```
2. This is the header for the full class. It inherits the PowerToyModuleIface
```c++
class ModuleInterface : public PowertoyModuleIface
{
private:
// the private members of the class
// Can include the enabled variable, logic for event handlers, or hotkeys.
public:
// the public members of the class
// Will include the constructor and initialization logic.
}
```
> [!NOTE]
> Many of the class functions are boilerplate and need simple string replacements with your module name. The rest of the functions below will require bigger changes.
3. GPO stands for "Group Policy Object" and allows for administrators to configure settings across a network of machines. It is required that your module is on this list of settings. You can right click the `powertoys_gpo` object to go to the definition and set up the `getConfiguredModuleEnabledValue` for your module.
```c++
virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override
{
return powertoys_gpo::getConfiguredModuleEnabledValue();
}
```
4. `init_settings()` initializes the settings for the interface. Will either pull from existing settings.json or use defaults.
```c++
void ModuleInterface::init_settings()
```
5. `get_config` retrieves the settings from the settings.json file.
```c++
virtual bool get_config(wchar_t* buffer, int* buffer_size) override
```
6. `set_config` sets the new settings to the settings.json file.
```c++
virtual void set_config(const wchar_t* config) override
```
7. `call_custom_action` allows custom actions to be called based on signals from the settings app.
```c++
void call_custom_action(const wchar_t* action) override
```
8. Lifecycle events control whether the module is enabled or not, as well as the default status of the module.
```c++
virtual void enable() // starts the module
virtual void disable() // terminates the module and performs any cleanup
virtual bool is_enabled() // returns if the module is currently enabled
virtual bool is_enabled_by_default() const override // allows the module to dictate whether it should be enabled by default in the PowerToys app.
```
9. Hotkey functions control the status of the hotkey.
```c++
// takes the hotkey from settings into a format that the interface can understand
void parse_hotkey(PowerToysSettings::PowerToyValues& settings)
// returns the hotkeys from settings
virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override
// performs logic when the hotkey event is fired
virtual bool on_hotkey(size_t hotkeyId) override
```
### Notes
- Keep module logic isolated under `/modules/<YourModule>`
- Use shared utilities from [`common`](https://github.com/microsoft/PowerToys/tree/main/src/common) instead of cross-module dependencies
- init/set/get config use preset functions to access the settings. Check out the [`settings_objects.h`](https://github.com/microsoft/PowerToys/blob/main/src/common/SettingsAPI/settings_helpers.h) in `src\common\SettingsAPI`
---
## 3. Bootstrapping your module
1. Use the [template](https://github.com/microsoft/PowerToys/tree/main/tools/project_template) to generate the module interface starter code.
2. Update all projects and namespaces with your module name.
3. Update GUIDs in `.vcxproj` and solution files.
4. Update the functions mentioned in the above section with your custom logic.
5. In order for your module to be detected by the runner you are required to add references to various lists. In order to register your module, add the corresponding module reference to the lists that can be found in the following files. (Hint: search other modules names to find the lists quicker)
- `src/runner/modules.h`
- `src/runner/modules.cpp`
- `src/runner/resource.h`
- `src/runner/settings_window.h`
- `src/runner/settings_window.cpp`
- `src/runner/main.cpp`
- `src/common/logger.h` (for logging)
6. ModuleInterface should build your `ModuleInterface.dll`. This will allow the runner to interact with your service.
> [!TIP]
> Mismatched module IDs are one of the most common causes of load failures. Keep your ID consistent across manifest, registry, and service.
---
## 4. Write your service
This is going to look different for every PowerToy. It may be easier to develop the application independently, and then link in the PowerToys settings logic later. But you have to write the service first, before connecting it to the runner.
### Notes
- This is a separate project from the Module Interface.
- You can develop this project using C# or C++.
- Set the service icon using the `.rc` file.
- Set the service name in the `.vcxproj` by setting the `<TargetName>`
```
<PropertyGroup>
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir>
<TargetName>PowerToys.LightSwitchService</TargetName>
</PropertyGroup>
```
- To view the code of the `.vcxproj`, right click the item and select **Unload project**
- Use the following functions to interact with settings from your service
```
ModuleSettings::instance().InitFileWatcher();
ModuleSettings::instance().LoadSettings();
auto& settings = ModuleSettings::instance().settings();
```
These come from the `ModuleSettings.h` file that lives with the Service. You can copy this from another module (e.g., Light Switch) and adjust to fit your needs.
If your module has a user interface:
- Use the **WinUI Blank App** template when setting up your project
- Use [Windows design best practices](https://learn.microsoft.com/windows/apps/design/basics/)
- Use the [WinUI 3 Gallery](https://apps.microsoft.com/detail/9p3jfpwwdzrc) for help with your UI code, and additional guidance.
## 5. Settings integration
PowerToys settings are stored per-module as JSON under:
```
%LOCALAPPDATA%\Microsoft\PowerToys\<module>\settings.json
```
### Implementation steps
- In `src\settings-ui\Settings.UI.Library\` create `<module>Properties.cs` and `<module>Settings.cs`
- `<module>Properties.cs` is where you will define your defaults. Every setting needs to be represented here. This should match what was set in the Module Interface.
- `<module>Settings.cs`is where your settings.json will be built from. The structure should match the following
```cs
public ModuleSettings()
{
Name = ModuleName;
Version = Assembly.GetExecutingAssembly().GetName().Version.ToString();
Properties = new ModuleProperties(); // settings properties you set above.
}
```
- In `src\settings-ui\Settings.UI\ViewModels` create `<module>ViewModel.cs` this is where the interaction happens between your settings page in the PowerToys app and the settings file that is stored on the device. Changes here will trigger the settings watcher via a `NotifyPropertyChanged` event.
- Create a `SettingsPage.xaml` at `src\settings-ui\Settings.UI\SettingsXAML\Views`. This will be the page where the user interacts with the settings of your module.
- Be sure to use resource strings for user facing strings so they can be localized. (`x:Uid` connects to Resources.resw)
```xaml
// LightSwitch.xaml
<ComboBoxItem
x:Uid="LightSwitch_ModeOff"
AutomationProperties.AutomationId="OffCBItem_LightSwitch"
Tag="Off" />
// Resources.resw
<data name="LightSwitch_ModeOff.Content" xml:space="preserve">
<value>Off</value>
</data>
```
> [!IMPORTANT]
> In the above example we use `.Content` to target the content of the Combobox. This can change per UI element (e.g., `.Text`, `.Header`, etc.)
> **Reminder:** Manual changes via external editors (VS Code, Notepad) do **not** trigger the settings watcher. Only changes written through PowerToys trigger reloads.
---
### Gotchas:
- Only use the WinUI 3 framework, _not_ UWP.
- Use [`DispatcherQueue`](https://learn.microsoft.com/windows/apps/develop/dispatcherqueue) when updating UI from non-UI threads.
---
## 6. Building and debugging
### Debugging steps
1. If this is your first time debugging PowerToys, be sure to follow [these steps first](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/development/debugging.md#pre-debugging-setup).
2. Set "runner" as the start up project and ensure your build configuration is set to match your system (ARM64/x64)
3. Select <kbd>F5</kbd> or the **Local Windows Debugger** button to begin debugging. This should start the PowerToys runner.
4. To set breakpoints in your service, select Ctrl+Alt+P and search for your service to attach to the runner.
5. Use logs to document changes. The logs live at `%LOCALAPPDATA%\Microsoft\PowerToys\RunnerLogs` and `%LOCALAPPDATA%\Microsoft\PowerToys\Module\Service\<version>` for the specific module.
> [!TIP]
> PowerToys caches `.nuget` artifacts aggressively. Use `git clean -xfd` when builds behave unexpectedly.
---
## 7. Installer and packaging (WiX)
### Add your module to installer
1. Install [`WixToolset.Heat`](https://www.nuget.org/packages/WixToolset.Heat/) for Wix5 via nuget
2. Inside `installer\PowerToysInstallerVNext` add a new file for your module: `Module.wxs`
3. Inside of this file you will need copy the format from another module (ie: Light Switch) and replace the strings and GUID values.
4. The key part will be `<!--ModuleNameFiles_Component_Def-->` which is a placeholder for code that will be generated by `generateFileComponents.ps1`.
5. Inside `Product.wxs` add a line item in the `<Feature Id="CoreFeature" ... >` section. It will look like a list of ` <ComponentGroupRef Id="ModuleComponentGroup" />` items.
6. Inside `generateFileComponents.ps1` you will need to add an entry to the bottom for your new module. It will follow the following format. `-fileListName <Module>Files` will match the string you set in `Module.wxs`, `<ModuleServiceName>` will match the name of your exe.
```bash
# Module Name
Generate-FileList -fileDepsJson "" -fileListName <Module>Files -wxsFilePath $PSScriptRoot\<Module>.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\<ModuleServiceName>"
Generate-FileComponents -fileListName "<Module>Files" -wxsFilePath $PSScriptRoot\<Module>.wxs -regroot $registryroot
```
---
## 8. Testing and validation
### UI tests
- Place under `/modules/<YourModule>/Tests`
- Create a new [WinUI Unit Test App](https://learn.microsoft.com/windows/apps/winui/winui3/testing/create-winui-unit-test-project)
- Write unit tests following the format from previous modules (ie: Light Switch). This can be to test your standalone UI (if you're a module like Color Picker) or to verify that the Settings UI in the PowerToys app is controlling your service.
### Manual validation
- Enable/disable in PowerToys Settings
- Check initialization in logs
- Confirm icons, tooltips, and OOBE page appear correctly
### Pro tips
1. Validate wake/sleep and elevation states. Background modules often fail silently after resume if event handles arent recreated.
2. Use Windows Sandbox to simulate clean install environments
3. To simulate a "new user" you can delete the PowerToys folder from `%LOCALAPPDATA%\Microsoft`
### Shortcut conflict detection
If your module has a shortcut, ensure that it is properly registered following [the steps listed in the documentation](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/core/settings/settings-implementation.md#shortcut-conflict-detection) for conflict detection.
---
## 9. The final touches
### Out-of-Box experience (OOBE) page
The OOBE page is a custom settings page that gives the user at a glance information about each module. This window opens before the Settings application for new users and after updates. Create `OOBE<ModuleName>.xaml` at `src\settings-ui\Settings.UI\SettingsXAML\OOBE\Views`. You will also need to add your module name to the enum at `src\settings-ui\Settings.UI\OOBE\Enums\PowerToysModules.cs`.
### Module assets
Now that your PowerToy is _done_ you can start to think about the assets that will represent your module.
- Module Icon: This will be displayed in a number of places: OOBE page, in the README, on the home screen of PowerToys, on your individual module settings page, etc.
- Module Image: This is the image you see at the top of each individual settings page.
- OOBE Image: This is the header you see on the OOBE page for each module
> [!NOTE]
> This step is something that the Design team will handle internally to ensure consistency throughout the application. If you have ideas or recommendations on what the icon or screenshots should be for your module feel free to leave it in the "Additional Comments" section of the PR and the team will take it into consideration.
### Documentation
There are two types of documentation that will be required when submitting a new PowerToy:
1. Developer documentation: This will live in the [PowerToys repo](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/modules) at `/doc/devdocs/modules/` and should tell a developer how to work on your app. It should outline the module architecture, key files, testing, and tips on debugging if necessary.
2. Microsoft Learn documentation: When your new Module is ready to be merged into the PowerToys repository, an internal team member will create Microsoft Learn documentation so that users will understand how to use your module. There is not much work on your end as the developer for this step, but keep an eye on your PR in case we need more information about your PowerToy for this step.
---
Thank you again for contributing! If you need help, feel free to [open an issue](https://github.com/microsoft/PowerToys/issues/new/choose) and use the `Needs-Team-Response` label so we know you need attention.

View File

@@ -18,13 +18,28 @@ Advanced Paste is a PowerToys module that provides enhanced clipboard pasting wi
TODO: Add implementation details
### Paste with AI Preview
The "Show preview" setting (`ShowCustomPreview`) controls whether AI-generated results are displayed in a preview window before pasting. **The preview feature does not consume additional AI credits**—the preview displays the same AI response that was already generated, cached locally from a single API call.
The implementation flow:
1. User initiates "Paste with AI" action
2. A single AI API call is made via `ExecutePasteFormatAsync`
3. The result is cached in `GeneratedResponses`
4. If preview is enabled, the cached result is displayed in the preview UI
5. User can paste the cached result without any additional API calls
See the `ExecutePasteFormatAsync(PasteFormat, PasteActionSource)` method in `OptionsViewModel.cs` for the implementation.
## Debugging
TODO: Add debugging information
## Settings
TODO: Add settings documentation
| Setting | Description |
|---------|-------------|
| `ShowCustomPreview` | When enabled, shows AI-generated results in a preview window before pasting. Does not affect AI credit consumption. |
## Future Improvements

View File

@@ -3,9 +3,27 @@
#include <iomanip>
#include <iostream>
#include <sstream>
#include <cmath>
#include <limits>
namespace ExprtkCalculator::internal
{
static double factorial(const double n)
{
// Only allow non-negative integers
if (n < 0.0 || std::floor(n) != n)
{
return std::numeric_limits<double>::quiet_NaN();
}
return std::tgamma(n + 1.0);
}
static double sign(const double n)
{
if (n > 0.0) return 1.0;
if (n < 0.0) return -1.0;
return 0.0;
}
std::wstring ToWStringFullPrecision(double value)
{
@@ -25,6 +43,9 @@ namespace ExprtkCalculator::internal
symbol_table.add_constant(name, value);
}
symbol_table.add_function("factorial", factorial);
symbol_table.add_function("sign", sign);
exprtk::expression<double> expression;
expression.register_symbol_table(symbol_table);

View File

@@ -0,0 +1,2 @@
// Fix for Issue #30340
namespace PowerToys.Fixes { public class Fix30340 { } }

View File

@@ -72,6 +72,10 @@ namespace CommonSharedConstants
const wchar_t ALWAYS_ON_TOP_TERMINATE_EVENT[] = L"Local\\AlwaysOnTopTerminateEvent-cfdf1eae-791f-4953-8021-2f18f3837eae";
const wchar_t ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT[] = L"Local\\AlwaysOnTopIncreaseOpacityEvent-a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const wchar_t ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT[] = L"Local\\AlwaysOnTopDecreaseOpacityEvent-b2c3d4e5-f6a7-8901-bcde-f12345678901";
// Path to the event used by PowerAccent
const wchar_t POWERACCENT_EXIT_EVENT[] = L"Local\\PowerToysPowerAccentExitEvent-53e93389-d19a-4fbb-9b36-1981c8965e17";

View File

@@ -153,9 +153,21 @@ LRESULT AlwaysOnTop::WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lp
{
if (message == WM_HOTKEY)
{
int hotkeyId = static_cast<int>(wparam);
if (HWND fw{ GetForegroundWindow() })
{
ProcessCommand(fw);
if (hotkeyId == static_cast<int>(HotkeyId::Pin))
{
ProcessCommand(fw);
}
else if (hotkeyId == static_cast<int>(HotkeyId::IncreaseOpacity))
{
StepWindowTransparency(fw, Settings::transparencyStep);
}
else if (hotkeyId == static_cast<int>(HotkeyId::DecreaseOpacity))
{
StepWindowTransparency(fw, -Settings::transparencyStep);
}
}
}
else if (message == WM_PRIV_SETTINGS_CHANGED)
@@ -191,6 +203,10 @@ void AlwaysOnTop::ProcessCommand(HWND window)
m_topmostWindows.erase(iter);
}
// Restore transparency when unpinning
RestoreWindowAlpha(window);
m_windowOriginalLayeredState.erase(window);
Trace::AlwaysOnTop::UnpinWindow();
}
}
@@ -200,6 +216,7 @@ void AlwaysOnTop::ProcessCommand(HWND window)
{
soundType = Sound::Type::On;
AssignBorder(window);
Trace::AlwaysOnTop::PinWindow();
}
}
@@ -269,11 +286,22 @@ void AlwaysOnTop::RegisterHotkey() const
{
if (m_useCentralizedLLKH)
{
// All hotkeys are handled by centralized LLKH
return;
}
// Register hotkeys only when not using centralized LLKH
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::Pin));
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity));
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity));
// Register pin hotkey
RegisterHotKey(m_window, static_cast<int>(HotkeyId::Pin), AlwaysOnTopSettings::settings().hotkey.get_modifiers(), AlwaysOnTopSettings::settings().hotkey.get_code());
// Register transparency hotkeys using the same modifiers as the pin hotkey
UINT modifiers = AlwaysOnTopSettings::settings().hotkey.get_modifiers();
RegisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity), modifiers, VK_OEM_PLUS);
RegisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity), modifiers, VK_OEM_MINUS);
}
void AlwaysOnTop::RegisterLLKH()
@@ -285,6 +313,8 @@ void AlwaysOnTop::RegisterLLKH()
m_hPinEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_PIN_EVENT);
m_hTerminateEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_TERMINATE_EVENT);
m_hIncreaseOpacityEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT);
m_hDecreaseOpacityEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT);
if (!m_hPinEvent)
{
@@ -298,30 +328,54 @@ void AlwaysOnTop::RegisterLLKH()
return;
}
HANDLE handles[2] = { m_hPinEvent,
m_hTerminateEvent };
if (!m_hIncreaseOpacityEvent)
{
Logger::warn(L"Failed to create increaseOpacityEvent. {}", get_last_error_or_default(GetLastError()));
}
if (!m_hDecreaseOpacityEvent)
{
Logger::warn(L"Failed to create decreaseOpacityEvent. {}", get_last_error_or_default(GetLastError()));
}
HANDLE handles[4] = { m_hPinEvent,
m_hTerminateEvent,
m_hIncreaseOpacityEvent,
m_hDecreaseOpacityEvent };
m_thread = std::thread([this, handles]() {
MSG msg;
while (m_running)
{
DWORD dwEvt = MsgWaitForMultipleObjects(2, handles, false, INFINITE, QS_ALLINPUT);
DWORD dwEvt = MsgWaitForMultipleObjects(4, handles, false, INFINITE, QS_ALLINPUT);
if (!m_running)
{
break;
}
switch (dwEvt)
{
case WAIT_OBJECT_0:
case WAIT_OBJECT_0: // Pin event
if (HWND fw{ GetForegroundWindow() })
{
ProcessCommand(fw);
}
break;
case WAIT_OBJECT_0 + 1:
case WAIT_OBJECT_0 + 1: // Terminate event
PostThreadMessage(m_mainThreadId, WM_QUIT, 0, 0);
break;
case WAIT_OBJECT_0 + 2:
case WAIT_OBJECT_0 + 2: // Increase opacity event
if (HWND fw{ GetForegroundWindow() })
{
StepWindowTransparency(fw, Settings::transparencyStep);
}
break;
case WAIT_OBJECT_0 + 3: // Decrease opacity event
if (HWND fw{ GetForegroundWindow() })
{
StepWindowTransparency(fw, -Settings::transparencyStep);
}
break;
case WAIT_OBJECT_0 + 4: // Message queue
if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
{
TranslateMessage(&msg);
@@ -370,9 +424,12 @@ void AlwaysOnTop::UnpinAll()
{
Logger::error(L"Unpinning topmost window failed");
}
// Restore transparency when unpinning all
RestoreWindowAlpha(topWindow);
}
m_topmostWindows.clear();
m_windowOriginalLayeredState.clear();
}
void AlwaysOnTop::CleanUp()
@@ -456,6 +513,7 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
for (const auto window : toErase)
{
m_topmostWindows.erase(window);
m_windowOriginalLayeredState.erase(window);
}
switch (data->event)
@@ -556,4 +614,166 @@ void AlwaysOnTop::RefreshBorders()
}
}
}
}
HWND AlwaysOnTop::ResolveTransparencyTargetWindow(HWND window)
{
if (!window || !IsWindow(window))
{
return nullptr;
}
// Only allow transparency changes on pinned windows
if (!IsPinned(window))
{
return nullptr;
}
return window;
}
void AlwaysOnTop::StepWindowTransparency(HWND window, int delta)
{
HWND targetWindow = ResolveTransparencyTargetWindow(window);
if (!targetWindow)
{
return;
}
int currentTransparency = Settings::maxTransparencyPercentage;
LONG exStyle = GetWindowLong(targetWindow, GWL_EXSTYLE);
if (exStyle & WS_EX_LAYERED)
{
BYTE alpha = 255;
if (GetLayeredWindowAttributes(targetWindow, nullptr, &alpha, nullptr))
{
currentTransparency = (alpha * 100) / 255;
}
}
int newTransparency = (std::max)(Settings::minTransparencyPercentage,
(std::min)(Settings::maxTransparencyPercentage, currentTransparency + delta));
if (newTransparency != currentTransparency)
{
ApplyWindowAlpha(targetWindow, newTransparency);
if (AlwaysOnTopSettings::settings().enableSound)
{
m_sound.Play(delta > 0 ? Sound::Type::IncreaseOpacity : Sound::Type::DecreaseOpacity);
}
Logger::debug(L"Transparency adjusted to {}%", newTransparency);
}
}
void AlwaysOnTop::ApplyWindowAlpha(HWND window, int percentage)
{
if (!window || !IsWindow(window))
{
return;
}
percentage = (std::max)(Settings::minTransparencyPercentage,
(std::min)(Settings::maxTransparencyPercentage, percentage));
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
bool isCurrentlyLayered = (exStyle & WS_EX_LAYERED) != 0;
// Cache original state on first transparency application
if (m_windowOriginalLayeredState.find(window) == m_windowOriginalLayeredState.end())
{
WindowLayeredState state;
state.hadLayeredStyle = isCurrentlyLayered;
if (isCurrentlyLayered)
{
BYTE alpha = 255;
COLORREF colorKey = 0;
DWORD flags = 0;
if (GetLayeredWindowAttributes(window, &colorKey, &alpha, &flags))
{
state.originalAlpha = alpha;
state.usedColorKey = (flags & LWA_COLORKEY) != 0;
state.colorKey = colorKey;
}
else
{
Logger::warn(L"GetLayeredWindowAttributes failed for layered window, skipping");
return;
}
}
m_windowOriginalLayeredState[window] = state;
}
// Clear WS_EX_LAYERED first to ensure SetLayeredWindowAttributes works
if (isCurrentlyLayered)
{
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
exStyle = GetWindowLong(window, GWL_EXSTYLE);
}
BYTE alphaValue = static_cast<BYTE>((255 * percentage) / 100);
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
SetLayeredWindowAttributes(window, 0, alphaValue, LWA_ALPHA);
}
void AlwaysOnTop::RestoreWindowAlpha(HWND window)
{
if (!window || !IsWindow(window))
{
return;
}
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
auto it = m_windowOriginalLayeredState.find(window);
if (it != m_windowOriginalLayeredState.end())
{
const auto& originalState = it->second;
if (originalState.hadLayeredStyle)
{
// Window originally had WS_EX_LAYERED - restore original attributes
// Clear and re-add to ensure clean state
if (exStyle & WS_EX_LAYERED)
{
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
exStyle = GetWindowLong(window, GWL_EXSTYLE);
}
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
// Restore original alpha and/or color key
DWORD flags = LWA_ALPHA;
if (originalState.usedColorKey)
{
flags |= LWA_COLORKEY;
}
SetLayeredWindowAttributes(window, originalState.colorKey, originalState.originalAlpha, flags);
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
}
else
{
// Window originally didn't have WS_EX_LAYERED - remove it completely
if (exStyle & WS_EX_LAYERED)
{
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
}
}
m_windowOriginalLayeredState.erase(it);
}
else
{
// Fallback: no cached state, just remove layered style
if (exStyle & WS_EX_LAYERED)
{
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
}
}
}

View File

@@ -10,6 +10,7 @@
#include <common/hooks/WinHookEvent.h>
#include <common/notifications/NotificationUtil.h>
#include <common/utils/window.h>
class AlwaysOnTop : public SettingsObserver
{
@@ -38,6 +39,8 @@ private:
enum class HotkeyId : int
{
Pin = 1,
IncreaseOpacity = 2,
DecreaseOpacity = 3,
};
static inline AlwaysOnTop* s_instance = nullptr;
@@ -48,8 +51,20 @@ private:
HWND m_window{ nullptr };
HINSTANCE m_hinstance;
std::map<HWND, std::unique_ptr<WindowBorder>> m_topmostWindows{};
// Store original window layered state for proper restoration
struct WindowLayeredState {
bool hadLayeredStyle = false;
BYTE originalAlpha = 255;
bool usedColorKey = false;
COLORREF colorKey = 0;
};
std::map<HWND, WindowLayeredState> m_windowOriginalLayeredState{};
HANDLE m_hPinEvent;
HANDLE m_hTerminateEvent;
HANDLE m_hIncreaseOpacityEvent;
HANDLE m_hDecreaseOpacityEvent;
DWORD m_mainThreadId;
std::thread m_thread;
const bool m_useCentralizedLLKH;
@@ -78,6 +93,12 @@ private:
bool AssignBorder(HWND window);
void RefreshBorders();
// Transparency methods
HWND ResolveTransparencyTargetWindow(HWND window);
void StepWindowTransparency(HWND window, int delta);
void ApplyWindowAlpha(HWND window, int percentage);
void RestoreWindowAlpha(HWND window);
virtual void SettingsUpdate(SettingId type) override;
static void CALLBACK WinHookProc(HWINEVENTHOOK winEventHook,

View File

@@ -15,6 +15,9 @@ class SettingsObserver;
struct Settings
{
PowerToysSettings::HotkeyObject hotkey = PowerToysSettings::HotkeyObject::from_settings(true, true, false, false, 84); // win + ctrl + T
static constexpr int minTransparencyPercentage = 20; // minimum transparency (can't go below 20%)
static constexpr int maxTransparencyPercentage = 100; // maximum (fully opaque)
static constexpr int transparencyStep = 10; // step size for +/- adjustment
bool enableFrame = true;
bool enableSound = true;
bool roundCornersEnabled = true;

View File

@@ -2,7 +2,6 @@
#include "pch.h"
#include <atomic>
#include <mmsystem.h> // sound
class Sound
@@ -12,12 +11,10 @@ public:
{
On,
Off,
IncreaseOpacity,
DecreaseOpacity,
};
Sound()
: isPlaying(false)
{}
void Play(Type type)
{
BOOL success = false;
@@ -29,6 +26,12 @@ public:
case Type::Off:
success = PlaySound(TEXT("Media\\Speech Sleep.wav"), NULL, SND_FILENAME | SND_ASYNC);
break;
case Type::IncreaseOpacity:
success = PlaySound(TEXT("Media\\Windows Hardware Insert.wav"), NULL, SND_FILENAME | SND_ASYNC);
break;
case Type::DecreaseOpacity:
success = PlaySound(TEXT("Media\\Windows Hardware Remove.wav"), NULL, SND_FILENAME | SND_ASYNC);
break;
default:
break;
}
@@ -38,7 +41,4 @@ public:
Logger::error(L"Sound playing error");
}
}
private:
std::atomic<bool> isPlaying;
};

View File

@@ -105,17 +105,28 @@ public:
}
}
virtual bool on_hotkey(size_t /*hotkeyId*/) override
virtual bool on_hotkey(size_t hotkeyId) override
{
if (m_enabled)
{
Logger::trace(L"AlwaysOnTop hotkey pressed");
Logger::trace(L"AlwaysOnTop hotkey pressed, id={}", hotkeyId);
if (!is_process_running())
{
Enable();
}
SetEvent(m_hPinEvent);
if (hotkeyId == 0)
{
SetEvent(m_hPinEvent);
}
else if (hotkeyId == 1)
{
SetEvent(m_hIncreaseOpacityEvent);
}
else if (hotkeyId == 2)
{
SetEvent(m_hDecreaseOpacityEvent);
}
return true;
}
@@ -125,19 +136,48 @@ public:
virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override
{
size_t count = 0;
// Hotkey 0: Pin/Unpin (e.g., Win+Ctrl+T)
if (m_hotkey.key)
{
if (hotkeys && buffer_size >= 1)
if (hotkeys && buffer_size > count)
{
hotkeys[0] = m_hotkey;
hotkeys[count] = m_hotkey;
Logger::trace(L"AlwaysOnTop hotkey[0]: win={}, ctrl={}, shift={}, alt={}, key={}",
m_hotkey.win, m_hotkey.ctrl, m_hotkey.shift, m_hotkey.alt, m_hotkey.key);
}
count++;
}
return 1;
}
else
// Hotkey 1: Increase opacity (same modifiers + VK_OEM_PLUS '=')
if (m_hotkey.key)
{
return 0;
if (hotkeys && buffer_size > count)
{
hotkeys[count] = m_hotkey;
hotkeys[count].key = VK_OEM_PLUS; // '=' key
Logger::trace(L"AlwaysOnTop hotkey[1] (increase opacity): win={}, ctrl={}, shift={}, alt={}, key={}",
hotkeys[count].win, hotkeys[count].ctrl, hotkeys[count].shift, hotkeys[count].alt, hotkeys[count].key);
}
count++;
}
// Hotkey 2: Decrease opacity (same modifiers + VK_OEM_MINUS '-')
if (m_hotkey.key)
{
if (hotkeys && buffer_size > count)
{
hotkeys[count] = m_hotkey;
hotkeys[count].key = VK_OEM_MINUS; // '-' key
Logger::trace(L"AlwaysOnTop hotkey[2] (decrease opacity): win={}, ctrl={}, shift={}, alt={}, key={}",
hotkeys[count].win, hotkeys[count].ctrl, hotkeys[count].shift, hotkeys[count].alt, hotkeys[count].key);
}
count++;
}
Logger::trace(L"AlwaysOnTop get_hotkeys returning count={}", count);
return count;
}
// Enable the powertoy
@@ -175,6 +215,8 @@ public:
app_key = NonLocalizable::ModuleKey;
m_hPinEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_PIN_EVENT);
m_hTerminateEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_TERMINATE_EVENT);
m_hIncreaseOpacityEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT);
m_hDecreaseOpacityEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT);
init_settings();
}
@@ -292,6 +334,8 @@ private:
// Handle to event used to pin/unpin windows
HANDLE m_hPinEvent;
HANDLE m_hTerminateEvent;
HANDLE m_hIncreaseOpacityEvent;
HANDLE m_hDecreaseOpacityEvent;
};
extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create()

View File

@@ -15,6 +15,7 @@
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI.ViewModels\\Microsoft.CmdPal.UI.ViewModels.csproj",
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI\\Microsoft.CmdPal.UI.csproj",
"src\\modules\\cmdpal\\Microsoft.Terminal.UI\\Microsoft.Terminal.UI.vcxproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Core.Common.UnitTests\\Microsoft.CmdPal.Core.Common.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Apps.UnitTests\\Microsoft.CmdPal.Ext.Apps.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj",
@@ -29,6 +30,7 @@
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.WindowWalker.UnitTests\\Microsoft.CmdPal.Ext.WindowWalker.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UI.ViewModels.UnitTests\\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UITests\\Microsoft.CmdPal.UITests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj",
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Apps\\Microsoft.CmdPal.Ext.Apps.csproj",
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Bookmark\\Microsoft.CmdPal.Ext.Bookmarks.csproj",
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Calc\\Microsoft.CmdPal.Ext.Calc.csproj",

View File

@@ -9,4 +9,18 @@
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,76 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace Microsoft.CmdPal.Core.Common.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Core.Common.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to This is an error report generated by Windows Command Palette.
///If you are seeing this, it means something went a little sideways in the app.
///You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
///
///(While youre at it, give the details below a quick skim — just to make sure theres nothing personal youd prefer not to share. Its rare, but sometimes little surprises sneak in.).
/// </summary>
internal static string ErrorReport_Global_Preamble {
get {
return ResourceManager.GetString("ErrorReport_Global_Preamble", resourceCulture);
}
}
}
}

View File

@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ErrorReport_Global_Preamble" xml:space="preserve">
<value>This is an error report generated by Windows Command Palette.
If you are seeing this, it means something went a little sideways in the app.
You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
(While youre at it, give the details below a quick skim — just to make sure theres nothing personal youd prefer not to share. Its rare, but sometimes little surprises sneak in.)</value>
</data>
</root>

View File

@@ -0,0 +1,118 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Principal;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
using Windows.ApplicationModel;
namespace Microsoft.CmdPal.Core.Common.Services.Reports;
public sealed class ErrorReportBuilder : IErrorReportBuilder
{
private readonly ErrorReportSanitizer _sanitizer = new();
private static string Preamble => Properties.Resources.ErrorReport_Global_Preamble;
public string BuildReport(Exception exception, string context, bool redactPii = true)
{
ArgumentNullException.ThrowIfNull(exception);
var exceptionMessage = CoalesceExceptionMessage(exception);
var sanitizedMessage = redactPii ? _sanitizer.Sanitize(exceptionMessage) : exceptionMessage;
var sanitizedFormattedException = redactPii ? _sanitizer.Sanitize(exception.ToString()) : exception.ToString();
// Note:
// - do not localize technical part of the report, we need to ensure it can be read by developers
// - keep timestamp format should be consistent with the log (makes it easier to search)
var technicalContent =
$"""
============================================================
Summary:
Message: {sanitizedMessage}
Type: {exception.GetType().FullName}
Source: {exception.Source ?? "N/A"}
Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fffffff}
HRESULT: 0x{exception.HResult:X8} ({exception.HResult})
Context: {context ?? "N/A"}
Application:
App version: {GetAppVersionSafe()}
Is elevated: {GetElevationStatus()}
Environment:
OS version: {RuntimeInformation.OSDescription}
OS architecture: {RuntimeInformation.OSArchitecture}
Runtime identifier: {RuntimeInformation.RuntimeIdentifier}
Framework: {RuntimeInformation.FrameworkDescription}
Process architecture: {RuntimeInformation.ProcessArchitecture}
Culture: {CultureInfo.CurrentCulture.Name}
UI culture: {CultureInfo.CurrentUICulture.Name}
Stack Trace:
{exception.StackTrace}
------------------ Full Exception Details ------------------
{sanitizedFormattedException}
============================================================
""";
return $"""
{Preamble}
{technicalContent}
""";
}
private static string GetElevationStatus()
{
// Note: do not localize technical part of the report, we need to ensure it can be read by developers
try
{
var isElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
return isElevated ? "yes" : "no";
}
catch (Exception)
{
return "Failed to determine elevation status";
}
}
private static string GetAppVersionSafe()
{
// Note: do not localize technical part of the report, we need to ensure it can be read by developers
try
{
var version = Package.Current.Id.Version;
return $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}";
}
catch (Exception)
{
return "Failed to retrieve app version";
}
}
private static string CoalesceExceptionMessage(Exception exception)
{
// let's try to get a message from the exception or inferred it from the HRESULT
// to show at least something
var message = exception.Message;
if (string.IsNullOrWhiteSpace(message))
{
var temp = Marshal.GetExceptionForHR(exception.HResult)?.Message;
if (!string.IsNullOrWhiteSpace(temp))
{
message = temp + $" (inferred from HRESULT 0x{exception.HResult:X8})";
}
}
if (string.IsNullOrWhiteSpace(message))
{
message = "No message available";
}
return message;
}
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Services.Reports;
/// <summary>
/// Defines a contract for creating human-readable error reports from exceptions,
/// suitable for logs, telemetry, or user-facing diagnostics.
/// </summary>
/// <remarks>
/// Implementations should ensure reports are consistent and optionally redact
/// personally identifiable or sensitive information when requested.
/// </remarks>
public interface IErrorReportBuilder
{
/// <summary>
/// Builds a formatted error report for the specified <paramref name="exception"/> and <paramref name="context"/>.
/// </summary>
/// <param name="exception">The exception that triggered the error report.</param>
/// <param name="context">
/// A short, human-readable description of where or what was being executed when the error occurred
/// (e.g., the operation name, component, or scenario).
/// </param>
/// <param name="redactPii">
/// When true, attempts to remove or obfuscate personally identifiable or sensitive information
/// (such as file paths, emails, machine/usernames, tokens). Defaults to true.
/// </param>
/// <returns>
/// A formatted string containing the error report, suitable for logging or telemetry submission.
/// </returns>
string BuildReport(Exception exception, string context, bool redactPii = true);
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
/// <summary>
/// Defines a service that sanitizes text by applying a set of configurable, regex-based rules.
/// Typical use cases include masking secrets, removing PII, or normalizing logs.
/// </summary>
/// <remarks>
/// - Rules are applied in their registered order; rule ordering may affect the final output.
/// - Each rule should have a unique <c>description</c> that acts as its identifier.
/// </remarks>
/// <seealso cref="SanitizationRule"/>
public interface ITextSanitizer
{
/// <summary>
/// Sanitizes the specified input by applying all registered rules in order.
/// </summary>
/// <param name="input">The input text to sanitize. Implementations should handle <see langword="null"/> safely.</param>
/// <returns>The sanitized text after all rules are applied.</returns>
string Sanitize(string? input);
/// <summary>
/// Adds a sanitization rule using a .NET regular expression pattern and a replacement string.
/// </summary>
/// <param name="pattern">A .NET regular expression pattern used to match text to sanitize.</param>
/// <param name="replacement">
/// The replacement text used by <c>Regex.Replace</c>. Supports standard regex replacement tokens,
/// including numbered groups (<c>$1</c>) and named groups (<c>${name}</c>).
/// </param>
/// <param name="description">
/// A human-readable, unique identifier for the rule. Used to list, test, and remove the rule.
/// </param>
/// <remarks>
/// Implementations typically validate <paramref name="pattern"/> is a valid regex and may reject duplicate <paramref name="description"/> values.
/// </remarks>
void AddRule(string pattern, string replacement, string description = "");
/// <summary>
/// Removes a previously added rule identified by its <paramref name="description"/>.
/// </summary>
/// <param name="description">The unique description of the rule to remove.</param>
void RemoveRule(string description);
/// <summary>
/// Gets a read-only snapshot of the currently registered sanitization rules in application order.
/// </summary>
/// <returns>A read-only list of <see cref="SanitizationRule"/> items.</returns>
IReadOnlyList<SanitizationRule> GetRules();
/// <summary>
/// Tests a single rule, identified by <paramref name="ruleDescription"/>, against the provided <paramref name="input"/>,
/// without applying other rules.
/// </summary>
/// <param name="input">The input text to test.</param>
/// <param name="ruleDescription">The description (identifier) of the rule to test.</param>
/// <returns>The result of applying only the specified rule to the input.</returns>
string TestRule(string input, string ruleDescription);
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.RegularExpressions;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
public readonly record struct SanitizationRule
{
public SanitizationRule(Regex regex, string replacement, string description = "")
{
Regex = regex;
Replacement = replacement;
Evaluator = null;
Description = description;
}
public SanitizationRule(Regex regex, MatchEvaluator evaluator, string description = "")
{
Regex = regex;
Evaluator = evaluator;
Replacement = null;
Description = description;
}
public Regex Regex { get; }
public string? Replacement { get; }
public MatchEvaluator? Evaluator { get; }
public string Description { get; }
public override string ToString() => $"{Description}: {Regex} -> {Replacement ?? "<evaluator>"}";
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.RegularExpressions;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
internal sealed partial class ConnectionStringRuleProvider : ISanitizationRuleProvider
{
[GeneratedRegex(@"(Server|Data Source|Initial Catalog|Database|User ID|Username|Password|Pwd|Uid)\s*=\s*(?:""[^""]*""|'[^']*'|[^;,\s]+)",
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex ConnectionParamRx();
public IEnumerable<SanitizationRule> GetRules()
{
yield return new(ConnectionParamRx(), "$1=[REDACTED]", "Connection string parameters");
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.RegularExpressions;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
internal sealed class EnvironmentPropertiesRuleProvider : ISanitizationRuleProvider
{
public IEnumerable<SanitizationRule> GetRules()
{
List<SanitizationRule> rules = [];
var machine = Environment.MachineName;
if (!string.IsNullOrWhiteSpace(machine))
{
var rx = new Regex(@"\b" + Regex.Escape(machine) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
rules.Add(new(rx, "[MACHINE_NAME_REDACTED]", "Machine name"));
}
var domain = Environment.UserDomainName;
if (!string.IsNullOrWhiteSpace(domain))
{
var rx = new Regex(@"\b" + Regex.Escape(domain) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
rules.Add(new(rx, "[USER_DOMAIN_NAME_REDACTED]", "User domain name"));
}
return rules;
}
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
/// <summary>
/// Specific sanitizer used for error report content. Builds on top of the generic TextSanitizer.
/// </summary>
public sealed class ErrorReportSanitizer
{
private readonly TextSanitizer _sanitizer = new(BuildProviders(), onGuardrailTriggered: OnGuardrailTriggered);
private static void OnGuardrailTriggered(GuardrailEventArgs eventArgs)
{
var msg = $"Sanitization guardrail triggered for rule '{eventArgs.RuleDescription}': original length={eventArgs.OriginalLength}, result length={eventArgs.ResultLength}, ratio={eventArgs.Ratio:F2}, threshold={eventArgs.Threshold:F2}";
CoreLogger.LogDebug(msg);
}
private static IEnumerable<ISanitizationRuleProvider> BuildProviders()
{
// Order matters
return
[
new PiiRuleProvider(),
new UrlRuleProvider(),
new NetworkRuleProvider(),
new TokenRuleProvider(),
new ConnectionStringRuleProvider(),
new SecretKeyValueRulesProvider(),
new EnvironmentPropertiesRuleProvider(),
new FilenameMaskRuleProvider(),
new ProfilePathAndUsernameRuleProvider()
];
}
public string Sanitize(string? input) => _sanitizer.Sanitize(input);
public string SanitizeException(Exception? exception)
{
if (exception is null)
{
return string.Empty;
}
var fullMessage = GetFullExceptionMessage(exception);
return Sanitize(fullMessage);
}
private static string GetFullExceptionMessage(Exception exception)
{
List<string> messages = [];
var current = exception;
var depth = 0;
// Prevent infinite loops on pathological InnerException graphs
while (current is not null && depth < 10)
{
messages.Add($"{current.GetType().Name}: {current.Message}");
if (!string.IsNullOrEmpty(current.StackTrace))
{
messages.Add($"Stack Trace: {current.StackTrace}");
}
current = current.InnerException;
depth++;
}
return string.Join(Environment.NewLine, messages);
}
public void AddRule(string pattern, string replacement, string description = "")
=> _sanitizer.AddRule(pattern, replacement, description);
public void RemoveRule(string description)
=> _sanitizer.RemoveRule(description);
public IReadOnlyList<SanitizationRule> GetRules() => _sanitizer.GetRules();
public string TestRule(string input, string ruleDescription)
=> _sanitizer.TestRule(input, ruleDescription);
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Frozen;
using System.Text.RegularExpressions;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider
{
private static readonly FrozenSet<string> CommonFileStemExclusions = new[]
{
"settings",
"config",
"configuration",
"appsettings",
"options",
"prefs",
"preferences",
"squirrel",
"app",
"system",
"env",
"environment",
"manifest",
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
public IEnumerable<SanitizationRule> GetRules()
{
const string pattern = """
(?<full>
(?: [A-Za-z]: )? (?: [\\/][^\\/:*?""<>|\s]+ )+ # drive-rooted or UNC-like
| [^\\/:*?""<>|\s]+ (?: [\\/][^\\/:*?""<>|\s]+ )+ # relative with at least one sep
)
""";
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
yield return new SanitizationRule(rx, MatchEvaluator, "Mask filename in any path");
yield break;
static string MatchEvaluator(Match m)
{
var full = m.Groups["full"].Value;
var lastSep = Math.Max(full.LastIndexOf('\\'), full.LastIndexOf('/'));
if (lastSep < 0 || lastSep == full.Length - 1)
{
return full;
}
var dir = full[..(lastSep + 1)];
var file = full[(lastSep + 1)..];
var dot = file.LastIndexOf('.');
var looksLikeFile = (dot > 0 && dot < file.Length - 1) || (file.StartsWith('.') && file.Length > 1);
if (!looksLikeFile)
{
return full;
}
string stem, ext;
if (dot > 0 && dot < file.Length - 1)
{
stem = file[..dot];
ext = file[dot..];
}
else
{
stem = file;
ext = string.Empty;
}
if (!ShouldMaskFileName(stem))
{
return dir + file;
}
var masked = MaskStem(stem) + ext;
return dir + masked;
}
}
private static string NormalizeStem(string stem)
{
return stem.Replace("-", string.Empty, StringComparison.Ordinal)
.Replace("_", string.Empty, StringComparison.Ordinal)
.Replace(".", string.Empty, StringComparison.Ordinal);
}
private static bool ShouldMaskFileName(string stem)
{
return !CommonFileStemExclusions.Contains(NormalizeStem(stem));
}
private static string MaskStem(string stem)
{
if (string.IsNullOrEmpty(stem))
{
return stem;
}
var keep = Math.Min(2, stem.Length);
var maskedCount = Math.Max(1, stem.Length - keep);
return stem[..keep] + new string('*', maskedCount);
}
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
public record GuardrailEventArgs(
string RuleDescription,
int OriginalLength,
int ResultLength,
double Threshold)
{
public double Ratio => OriginalLength > 0 ? (double)ResultLength / OriginalLength : 1.0;
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
internal interface ISanitizationRuleProvider
{
IEnumerable<SanitizationRule> GetRules();
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.RegularExpressions;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
internal sealed partial class NetworkRuleProvider : ISanitizationRuleProvider
{
public IEnumerable<SanitizationRule> GetRules()
{
yield return new(Ipv4Rx(), "[IP4_REDACTED]", "IP addresses");
yield return new(Ipv6BracketedRx(), "[IP6_REDACTED]", "IPv6 addresses (bracketed/with port)");
yield return new(Ipv6Rx(), "[IP6_REDACTED]", "IPv6 addresses");
yield return new(MacAddressRx(), "[MAC_ADDRESS_REDACTED]", "MAC addresses");
}
[GeneratedRegex(@"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b",
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex Ipv4Rx();
[GeneratedRegex(
"""
(?ix) # ignore case/whitespace
(?<![A-F0-9:]) # left edge
(
(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} | # 1:2:3:4:5:6:7:8
(?:[A-F0-9]{1,4}:){1,7}: | # 1:: 1:2:...:7::
(?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} |
(?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} |
(?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} |
(?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} |
(?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} |
[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} |
:(?::[A-F0-9]{1,4}){1,7} | # ::, ::1, etc.
(?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} | # IPv4 tail
(?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} |
[A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} |
:(?:\d{1,3}\.){3}\d{1,3}
)
(?:%\w+)? # optional zone id
(?![A-F0-9:]) # right edge
""",
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex Ipv6Rx();
[GeneratedRegex(
"""
(?ix)
\[
(
(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} |
(?:[A-F0-9]{1,4}:){1,7}: |
(?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} |
(?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} |
(?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} |
(?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} |
(?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} |
[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} |
:(?::[A-F0-9]{1,4}){1,7} |
(?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} |
(?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} |
[A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} |
:(?:\d{1,3}\.){3}\d{1,3}
)
(?:%\w+)? # optional zone id
\]
(?: : (?<port>\d{1,5}) )? # optional port
""",
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex Ipv6BracketedRx();
[GeneratedRegex(@"\b(?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2}|[0-9A-Fa-f]{1,2})\b",
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex MacAddressRx();
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.RegularExpressions;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
internal sealed partial class PiiRuleProvider : ISanitizationRuleProvider
{
public IEnumerable<SanitizationRule> GetRules()
{
yield return new(EmailRx(), "[EMAIL_REDACTED]", "Email addresses");
yield return new(SsnRx(), "[SSN_REDACTED]", "Social Security Numbers");
yield return new(CreditCardRx(), "[CARD_REDACTED]", "Credit card numbers");
// phone number regex is the most generic, so it goes last
// we can't make this too generic; otherwise we over-redact error codes, dates, etc.
yield return new(PhoneRx(), "[PHONE_REDACTED]", "Phone numbers");
}
[GeneratedRegex(@"\b[a-zA-Z0-9]([a-zA-Z0-9._%-]*[a-zA-Z0-9])?@[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?\.[a-zA-Z]{2,}\b",
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex EmailRx();
[GeneratedRegex("""
(?xi)
# ---------- boundaries ----------
(?<!\w) # not after a letter/digit/underscore
(?<![A-Za-z0-9]-) # avoid starting inside hyphenated tokens (GUID middles, etc.)
# ---------- global do-not-match guards ----------
(?! # ISO date (yyyy-mm-dd / yyyy.mm.dd / yyyy/mm/dd)
(?:19|20)\d{2}[-./](?:0[1-9]|1[0-2])[-./](?:0[1-9]|[12]\d|3[01])\b
)
(?! # EU date (dd-mm-yyyy / dd.mm.yyyy / dd/mm/yyyy)
(?:0[1-9]|[12]\d|3[01])[-./](?:0[1-9]|1[0-2])[-./](?:19|20)\d{2}\b
)
(?! # ISO datetime like 2025-08-24T14:32[:ss][Z|±hh:mm]
(?:19|20)\d{2}-\d{2}-\d{2}[T\s]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?\b
)
(?!\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?\b) # IPv4 with optional :port
(?!\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\b) # GUID, lowercase
(?!\b[0-9A-F]{8}-(?:[0-9A-F]{4}-){3}[0-9A-F]{12}\b) # GUID, uppercase
(?!\bv?\d+(?:\.\d+){2,}\b) # semantic/file versions like 1.2.3 or 10.0.22631.3448
(?!\b(?:[0-9A-F]{2}[:-]){5}[0-9A-F]{2}\b) # MAC address
# ---------- digit budget ----------
(?=(?:\D*\d){7,15}) # 715 digits in total
# ---------- number body ----------
(?:
# A with explicit country code, allow compact digits (E.164-ish) or grouped
(?:\+|00)[1-9]\d{0,2}
(?:
[\p{Zs}.\-\/]*\d{6,14}
|
[\p{Zs}.\-\/]* (?:\(\d{1,4}\)|\d{1,4})
(?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6}
)
|
# B no country code => require separators between blocks (avoid plain big ints)
(?:\(\d{1,4}\)|\d{1,4})
(?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6}
)
# ---------- optional extension ----------
(?:[\p{Zs}.\-,:;]* (?:ext\.?|x) [\p{Zs}]* (?<ext>\d{1,6}))?
(?!-\w) # don't end just before '-letter'/'-digit'
""",
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex PhoneRx();
[GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b",
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex SsnRx();
[GeneratedRegex(@"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b",
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex CreditCardRx();
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Frozen;
using System.Text.RegularExpressions;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
internal sealed class ProfilePathAndUsernameRuleProvider : ISanitizationRuleProvider
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs);
private readonly Dictionary<string, string> _profilePaths = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _usernames = new(StringComparer.OrdinalIgnoreCase);
private static readonly FrozenSet<string> CommonPathParts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Users", "home", "Documents", "Desktop", "AppData", "Local", "Roaming",
"Pictures", "Videos", "Music", "Downloads", "Program Files", "Windows",
"System32", "bin", "usr", "var", "etc", "opt", "tmp",
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
private static readonly FrozenSet<string> CommonWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"admin", "user", "test", "guest", "public", "system", "service",
"default", "temp", "local", "shared", "common", "data", "config",
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
public ProfilePathAndUsernameRuleProvider()
{
DetectSystemPaths();
}
public IEnumerable<SanitizationRule> GetRules()
{
List<SanitizationRule> rules = [];
// Profile path rules (ordered longest-first)
var orderedRules = _profilePaths
.Where(p => !string.IsNullOrEmpty(p.Key))
.OrderByDescending(p => p.Key.Length);
foreach (var profilePath in orderedRules)
{
try
{
var normalizedPath = profilePath.Key
.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar);
var escapedPath = Regex.Escape(normalizedPath);
var pattern = escapedPath + @"(?:[/\\]*)";
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout);
rules.Add(new(rx, profilePath.Value, $"Profile path: {profilePath}"));
}
catch
{
// Skip problematic paths
}
}
// Username rules
foreach (var username in _usernames.Where(u => !string.IsNullOrEmpty(u) && u.Length > 2))
{
try
{
if (!IsLikelyUsername(username))
{
continue;
}
var rx = new Regex(@"\b" + Regex.Escape(username) + @"\b", SanitizerDefaults.DefaultOptions, DefaultTimeout);
rules.Add(new(rx, "[USERNAME_REDACTED]", $"Username: {username}"));
}
catch
{
// Skip problematic usernames
}
}
return rules;
}
public IReadOnlyDictionary<string, string> GetDetectedProfilePaths() => _profilePaths;
public IReadOnlyCollection<string> GetDetectedUsernames() => _usernames;
private void DetectSystemPaths()
{
try
{
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (!string.IsNullOrEmpty(userProfile) && Directory.Exists(userProfile))
{
_profilePaths.Add(userProfile, "[USER_PROFILE_DIR]");
var username = Path.GetFileName(userProfile);
if (!string.IsNullOrEmpty(username) && username.Length > 2)
{
_usernames.Add(username);
}
}
Environment.SpecialFolder[] profileFolders =
[
Environment.SpecialFolder.ApplicationData,
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolder.Desktop,
Environment.SpecialFolder.MyDocuments,
Environment.SpecialFolder.MyPictures,
Environment.SpecialFolder.MyVideos,
Environment.SpecialFolder.MyMusic,
Environment.SpecialFolder.StartMenu,
Environment.SpecialFolder.Startup,
Environment.SpecialFolder.DesktopDirectory
];
foreach (var folder in profileFolders)
{
var dir = Environment.GetFolderPath(folder);
if (string.IsNullOrEmpty(dir))
{
continue;
}
var added = _profilePaths.TryAdd(dir, $"[{folder.ToString().ToUpperInvariant()}_DIR]");
if (!added)
{
continue;
}
}
string[] envVars = ["USERPROFILE", "HOME", "OneDrive", "OneDriveCommercial"];
foreach (var envVar in envVars)
{
var envPath = Environment.GetEnvironmentVariable(envVar);
if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath))
{
_profilePaths.TryAdd(envPath, $"[{envVar.ToUpperInvariant()}_DIR]");
}
}
}
catch (Exception ex)
{
CoreLogger.LogError("Error detecting system profile paths and usernames", ex);
}
}
private static bool IsLikelyUsername(string username) =>
!CommonWords.Contains(username) &&
username.Length is >= 3 and <= 50 &&
!username.All(char.IsDigit);
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.RegularExpressions;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
internal static class SanitizerDefaults
{
public const RegexOptions DefaultOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled;
public const int DefaultMatchTimeoutMs = 100;
}

View File

@@ -0,0 +1,172 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Frozen;
using System.Text.RegularExpressions;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
internal sealed class SecretKeyValueRulesProvider : ISanitizationRuleProvider
{
// Central list of common secret keys/phrases to redact when found in key=value pairs.
private static readonly FrozenSet<string> SecretKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
// Core passwords/secrets
"password",
"passphrase",
"passwd",
"pwd",
// Tokens
"token",
"access token",
"refresh token",
"id token",
"auth token",
"session token",
"bearer token",
"personal access token",
"pat",
// API / client credentials
"api key",
"api secret",
"x api key",
"client id",
"client secret",
"x client id",
"x client secret",
"consumer secret",
"service principal secret",
// Cloud & platform (Azure/AppInsights/etc.)
"subscription key",
"instrumentation key",
"account key",
"storage account key",
"shared access key",
"shared access signature",
"SAS token",
// Connection strings (often surfaced in exception messages)
"connection string",
"conn string",
"storage connection string",
// Certificates & crypto
"private key",
"certificate password",
"client certificate password",
"pfx password",
// AWS common keys
"aws access key id",
"aws secret access key",
"aws session token",
// Optional service aliases
"cosmos db key",
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
public IEnumerable<SanitizationRule> GetRules()
{
yield return BuildSecretKeyValueRule(
SecretKeys,
timeout: TimeSpan.FromSeconds(5),
starEverything: true);
}
private static SanitizationRule BuildSecretKeyValueRule(
IEnumerable<string> keys,
RegexOptions? options = null,
TimeSpan? timeout = null,
string label = "[REDACTED]",
bool treatDashUnderscoreAsSpace = true,
string separatorsClass = "[:=]", // char class for separators
string unquotedStopClass = "\\s",
bool starEverything = false)
{
ArgumentNullException.ThrowIfNull(keys);
// Between-word matcher for keys: "api key" -> "api\s*key" (optionally treating _/- as "space")
var between = treatDashUnderscoreAsSpace ? @"(?:\s|[_-])*" : @"\s*";
var patterns = new List<string>();
foreach (var raw in keys)
{
var key = raw?.Trim();
if (string.IsNullOrEmpty(key))
{
continue;
}
if (starEverything && key is not ['*', ..])
{
key = "*" + key;
}
if (key is ['*', .. var tail])
{
// Wildcard prefix: allow one non-space token + optional "-" or "_" before the remainder.
// Matches: "api key", "api-key", "azure-api-key", "user_api_key"
var remainder = tail.Trim();
if (remainder.Length == 0)
{
continue;
}
var rem = Normalize(remainder, between);
patterns.Add($@"(?:(?>[A-Za-z0-9_]{{1,128}}[_-]))?{rem}");
}
else
{
patterns.Add(Normalize(key, between));
}
}
if (patterns.Count == 0)
{
throw new ArgumentException("No non-empty keys provided.", nameof(keys));
}
var keysAlt = string.Join("|", patterns);
var pattern =
$"""
# Negative lookbehind to ensure the key is not part of a larger word
(?<![A-Za-z0-9])
# Match and capture the key (from the provided list)
(?<key>(?:{keysAlt}))
# Negative lookahead to ensure the key is not part of a larger word
(?![A-Za-z0-9])
# Optional whitespace between key and separator
\s*
# Separator (e.g., ':' or '=')
(?<sep>{separatorsClass})
# Optional whitespace after separator
\s*
# Match and capture the value, supporting quoted or unquoted values
(?:
# Quoted value: match opening quote, value, and closing quote
(?<q>["'])(?<val>[^"']+)\k<q>
|
# Unquoted value: match up to the next whitespace
(?<val>[^{unquotedStopClass}]+)
)
""";
var rx = new Regex(
pattern,
(options ?? (RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) | RegexOptions.IgnorePatternWhitespace,
timeout ?? TimeSpan.FromMilliseconds(1000));
var replacement = @"${key}${sep} ${q}" + label + @"${q}";
return new SanitizationRule(rx, replacement, "Sensitive key/value pairs");
static string Normalize(string s, string betweenSep)
=> Regex.Escape(s).Replace("\\ ", betweenSep);
}
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.RegularExpressions;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
/// <summary>
/// Generic text sanitizer that applies a sequence of regex-based rules over input text.
/// </summary>
internal sealed class TextSanitizer : ITextSanitizer
{
// Default guardrail: sanitized text must retain at least 30% of the original length
private const double DefaultGuardrailThreshold = 0.3;
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs);
private readonly List<SanitizationRule> _rules = [];
private readonly double _guardrailThreshold;
private readonly Action<GuardrailEventArgs>? _onGuardrailTriggered;
public TextSanitizer(
double guardrailThreshold = DefaultGuardrailThreshold,
Action<GuardrailEventArgs>? onGuardrailTriggered = null)
{
_guardrailThreshold = guardrailThreshold;
_onGuardrailTriggered = onGuardrailTriggered;
}
public TextSanitizer(
IEnumerable<ISanitizationRuleProvider> providers,
double guardrailThreshold = DefaultGuardrailThreshold,
Action<GuardrailEventArgs>? onGuardrailTriggered = null)
{
ArgumentNullException.ThrowIfNull(providers);
_guardrailThreshold = guardrailThreshold;
_onGuardrailTriggered = onGuardrailTriggered;
foreach (var p in providers)
{
try
{
_rules.AddRange(p.GetRules());
}
catch
{
// Best-effort; ignore provider errors
}
}
}
public string Sanitize(string? input)
{
if (string.IsNullOrEmpty(input))
{
return input ?? string.Empty;
}
var result = input;
foreach (var rule in _rules)
{
try
{
var previous = result;
result = rule.Evaluator is null
? rule.Regex.Replace(previous, rule.Replacement!)
: rule.Regex.Replace(previous, rule.Evaluator);
if (result.Length < previous.Length * _guardrailThreshold)
{
_onGuardrailTriggered?.Invoke(new GuardrailEventArgs(
rule.Description,
previous.Length,
result.Length,
_guardrailThreshold));
result = previous; // Guardrail
}
}
catch (RegexMatchTimeoutException)
{
// Ignore timeouts; keep the original input
}
catch
{
// Ignore other exceptions; keep the original input
}
}
return result;
}
public void AddRule(string pattern, string replacement, string description = "")
{
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout);
_rules.Add(new SanitizationRule(rx, replacement, description));
}
public void RemoveRule(string description)
{
_rules.RemoveAll(r => r.Description.Equals(description, StringComparison.OrdinalIgnoreCase));
}
public IReadOnlyList<SanitizationRule> GetRules() => _rules.AsReadOnly();
public string TestRule(string input, string ruleDescription)
{
var rule = _rules.FirstOrDefault(r => r.Description.Contains(ruleDescription, StringComparison.OrdinalIgnoreCase));
if (rule.Regex is null)
{
return input;
}
try
{
if (rule.Evaluator is not null)
{
return rule.Regex.Replace(input, rule.Evaluator);
}
if (rule.Replacement is not null)
{
return rule.Regex.Replace(input, rule.Replacement);
}
}
catch
{
// Ignore exceptions; return original input
}
return input;
}
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.RegularExpressions;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
internal sealed partial class TokenRuleProvider : ISanitizationRuleProvider
{
public IEnumerable<SanitizationRule> GetRules()
{
yield return new(JwtRx(), "[JWT_REDACTED]", "JSON Web Tokens (JWT)");
yield return new(TokenRx(), "[TOKEN_REDACTED]", "Potential API keys/tokens");
}
[GeneratedRegex(@"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b",
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex JwtRx();
[GeneratedRegex(@"\b[A-Za-z0-9]{32,128}\b",
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex TokenRx();
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.RegularExpressions;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
internal sealed partial class UrlRuleProvider : ISanitizationRuleProvider
{
public IEnumerable<SanitizationRule> GetRules()
{
yield return new(UrlRx(), "[URL_REDACTED]", "URLs");
}
[GeneratedRegex(@"\b(?:https?|ftp|ftps|file|jdbc|ldap|mailto)://[^\s<>""'{}\[\]\\^`|]+",
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex UrlRx();
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Concurrent;
using Microsoft.CmdPal.Core.Common;
using Microsoft.CmdPal.Core.Common.Helpers;
namespace Microsoft.CmdPal.Core.ViewModels;
internal static class BatchUpdateManager
{
private const int ExpectedBatchSize = 32;
// 30 ms chosen empirically to balance responsiveness and batching:
// - Keeps perceived latency low (< ~50 ms) for user-visible updates.
// - Still allows multiple COM/background events to be coalesced into a single batch.
private static readonly TimeSpan BatchDelay = TimeSpan.FromMilliseconds(30);
private static readonly ConcurrentQueue<IBatchUpdateTarget> DirtyQueue = [];
private static readonly Timer Timer = new(static _ => Flush(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
private static InterlockedBoolean _isFlushScheduled;
/// <summary>
/// Enqueue a target for batched processing. Safe to call from any thread (including COM callbacks).
/// </summary>
public static void Queue(IBatchUpdateTarget target)
{
if (!target.TryMarkBatchQueued())
{
return; // already queued in current batch window
}
DirtyQueue.Enqueue(target);
TryScheduleFlush();
}
private static void TryScheduleFlush()
{
if (!_isFlushScheduled.Set())
{
return;
}
if (DirtyQueue.IsEmpty)
{
_isFlushScheduled.Clear();
if (DirtyQueue.IsEmpty)
{
return;
}
if (!_isFlushScheduled.Set())
{
return;
}
}
try
{
Timer.Change(BatchDelay, Timeout.InfiniteTimeSpan);
}
catch (Exception ex)
{
_isFlushScheduled.Clear();
CoreLogger.LogError("Failed to arm batch timer.", ex);
}
}
private static void Flush()
{
try
{
var drained = new List<IBatchUpdateTarget>(ExpectedBatchSize);
while (DirtyQueue.TryDequeue(out var item))
{
drained.Add(item);
}
if (drained.Count == 0)
{
return;
}
// LOAD BEARING:
// ApplyPendingUpdates must run on a background thread.
// The VM itself is responsible for marshaling UI notifications to its _uiScheduler.
ApplyBatch(drained);
}
catch (Exception ex)
{
// Don't kill the timer thread.
CoreLogger.LogError("Batch flush failed.", ex);
}
finally
{
_isFlushScheduled.Clear();
TryScheduleFlush();
}
}
private static void ApplyBatch(List<IBatchUpdateTarget> items)
{
// Runs on the Timer callback thread (ThreadPool). That's fine: background work only.
foreach (var item in items)
{
// Allow re-queueing immediately if more COM events arrive during apply.
item.ClearBatchQueued();
try
{
item.ApplyPendingUpdates();
}
catch (Exception ex)
{
CoreLogger.LogError("Failed to apply pending updates for a batched target.", ex);
}
}
}
}
internal interface IBatchUpdateTarget
{
/// <summary>UI scheduler (used by targets internally for UI marshaling). Kept here for diagnostics / consistency.</summary>
TaskScheduler UIScheduler { get; }
/// <summary>Apply any coalesced updates. Must be safe to call on a background thread.</summary>
void ApplyPendingUpdates();
/// <summary>De-dupe gate: returns true only for the first enqueue until cleared.</summary>
bool TryMarkBatchQueued();
/// <summary>Clear the de-dupe gate so the item can be queued again.</summary>
void ClearBatchQueued();
}

View File

@@ -2,36 +2,99 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Buffers;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.Core.Common;
using Microsoft.CmdPal.Core.Common.Helpers;
namespace Microsoft.CmdPal.Core.ViewModels;
public abstract partial class ExtensionObjectViewModel : ObservableObject
public abstract partial class ExtensionObjectViewModel : ObservableObject, IBatchUpdateTarget, IBackgroundPropertyChangedNotification
{
public WeakReference<IPageContext> PageContext { get; set; }
private const int InitialPropertyBatchingBufferSize = 16;
internal ExtensionObjectViewModel(IPageContext? context)
{
var realContext = context ?? (this is IPageContext c ? c : throw new ArgumentException("You need to pass in an IErrorContext"));
PageContext = new(realContext);
}
// Raised on the background thread before UI notifications. It's raised on the background thread to prevent
// blocking the COM proxy.
public event PropertyChangedEventHandler? PropertyChangedBackground;
internal ExtensionObjectViewModel(WeakReference<IPageContext> context)
{
PageContext = context;
}
private readonly ConcurrentQueue<string> _pendingProps = [];
public async virtual Task InitializePropertiesAsync()
private readonly TaskScheduler _uiScheduler;
private InterlockedBoolean _batchQueued;
public WeakReference<IPageContext> PageContext { get; private set; } = null!;
TaskScheduler IBatchUpdateTarget.UIScheduler => _uiScheduler;
void IBatchUpdateTarget.ApplyPendingUpdates() => ApplyPendingUpdates();
bool IBatchUpdateTarget.TryMarkBatchQueued() => _batchQueued.Set();
void IBatchUpdateTarget.ClearBatchQueued() => _batchQueued.Clear();
private protected ExtensionObjectViewModel(TaskScheduler scheduler)
{
var t = new Task(() =>
if (this is not IPageContext)
{
SafeInitializePropertiesSynchronous();
});
t.Start();
await t;
throw new InvalidOperationException($"Constructor overload without IPageContext can only be used when the derived class implements IPageContext. Type: {GetType().FullName}");
}
_uiScheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
// Defer PageContext assignment - derived constructor MUST call InitializePageContext()
// or we set it lazily on first access
}
private protected ExtensionObjectViewModel(IPageContext context)
{
ArgumentNullException.ThrowIfNull(context);
PageContext = new WeakReference<IPageContext>(context);
_uiScheduler = context.Scheduler;
LogIfDefaultScheduler();
}
private protected ExtensionObjectViewModel(WeakReference<IPageContext> contextRef)
{
ArgumentNullException.ThrowIfNull(contextRef);
if (!contextRef.TryGetTarget(out var context))
{
throw new ArgumentException("IPageContext must be alive when creating view models.", nameof(contextRef));
}
PageContext = contextRef;
_uiScheduler = context.Scheduler;
LogIfDefaultScheduler();
}
protected void InitializeSelfAsPageContext()
{
if (this is not IPageContext self)
{
throw new InvalidOperationException("This method can only be called when the class implements IPageContext.");
}
PageContext = new WeakReference<IPageContext>(self);
}
private void LogIfDefaultScheduler()
{
if (_uiScheduler == TaskScheduler.Default)
{
CoreLogger.LogDebug($"ExtensionObjectViewModel created with TaskScheduler.Default. Type: {GetType().FullName}");
}
}
public virtual Task InitializePropertiesAsync()
=> Task.Run(SafeInitializePropertiesSynchronous);
public void SafeInitializePropertiesSynchronous()
{
try
@@ -46,49 +109,151 @@ public abstract partial class ExtensionObjectViewModel : ObservableObject
public abstract void InitializeProperties();
protected void UpdateProperty(string propertyName)
{
DoOnUiThread(() => OnPropertyChanged(propertyName));
}
protected void UpdateProperty(string propertyName) => MarkPropertyDirty(propertyName);
protected void UpdateProperty(string propertyName1, string propertyName2)
{
DoOnUiThread(() =>
{
OnPropertyChanged(propertyName1);
OnPropertyChanged(propertyName2);
});
}
protected void UpdateProperty(string propertyName1, string propertyName2, string propertyName3)
{
DoOnUiThread(() =>
{
OnPropertyChanged(propertyName1);
OnPropertyChanged(propertyName2);
OnPropertyChanged(propertyName3);
});
MarkPropertyDirty(propertyName1);
MarkPropertyDirty(propertyName2);
}
protected void UpdateProperty(params string[] propertyNames)
{
DoOnUiThread(() =>
foreach (var p in propertyNames)
{
foreach (var propertyName in propertyNames)
{
OnPropertyChanged(propertyName);
}
});
MarkPropertyDirty(p);
}
}
internal void MarkPropertyDirty(string? propertyName)
{
if (string.IsNullOrEmpty(propertyName))
{
return;
}
// We should re-consider if this worth deduping
_pendingProps.Enqueue(propertyName);
BatchUpdateManager.Queue(this);
}
public void ApplyPendingUpdates()
{
((IBatchUpdateTarget)this).ClearBatchQueued();
var buffer = ArrayPool<string>.Shared.Rent(InitialPropertyBatchingBufferSize);
var count = 0;
var transferred = false;
try
{
while (_pendingProps.TryDequeue(out var name))
{
if (count == buffer.Length)
{
var bigger = ArrayPool<string>.Shared.Rent(buffer.Length * 2);
Array.Copy(buffer, bigger, buffer.Length);
ArrayPool<string>.Shared.Return(buffer, clearArray: true);
buffer = bigger;
}
buffer[count++] = name;
}
if (count == 0)
{
return;
}
// 1) Background subscribers (must be raised before UI notifications).
var propertyChangedEventHandler = PropertyChangedBackground;
if (propertyChangedEventHandler is not null)
{
RaiseBackground(propertyChangedEventHandler, this, buffer, count);
}
// 2) UI-facing PropertyChanged: ALWAYS marshal to UI scheduler.
// Hand-off pooled buffer to UI task (UI task returns it).
//
// It would be lovely to do nothing if no one is actually listening on PropertyChanged,
// but ObservableObject doesn't expose that information.
_ = Task.Factory.StartNew(
static state =>
{
var p = (UiBatch)state!;
try
{
p.Owner.RaiseUi(p.Names, p.Count);
}
catch (Exception ex)
{
CoreLogger.LogError("Failed to raise property change notifications on UI thread.", ex);
}
finally
{
ArrayPool<string>.Shared.Return(p.Names, clearArray: true);
}
},
new UiBatch(this, buffer, count),
CancellationToken.None,
TaskCreationOptions.DenyChildAttach,
_uiScheduler);
transferred = true;
}
catch (Exception ex)
{
CoreLogger.LogError("Failed to apply pending property updates.", ex);
}
finally
{
if (!transferred)
{
ArrayPool<string>.Shared.Return(buffer, clearArray: true);
}
}
}
private void RaiseUi(string[] names, int count)
{
for (var i = 0; i < count; i++)
{
OnPropertyChanged(Args(names[i]));
}
}
private static void RaiseBackground(PropertyChangedEventHandler handlers, object sender, string[] names, int count)
{
try
{
for (var i = 0; i < count; i++)
{
handlers(sender, Args(names[i]));
}
}
catch (Exception ex)
{
CoreLogger.LogError("Failed to raise PropertyChangedBackground notifications.", ex);
}
}
private sealed record UiBatch(ExtensionObjectViewModel Owner, string[] Names, int Count);
protected void ShowException(Exception ex, string? extensionHint = null)
{
if (PageContext.TryGetTarget(out var pageContext))
{
pageContext.ShowException(ex, extensionHint);
}
else
{
CoreLogger.LogError("Failed to show exception because PageContext is no longer available.", ex);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static PropertyChangedEventArgs Args(string name) => new(name);
protected void DoOnUiThread(Action action)
{
if (PageContext.TryGetTarget(out var pageContext))

View File

@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.ComponentModel;
namespace Microsoft.CmdPal.Core.ViewModels;
/// <summary>
/// Provides a notification mechanism for property changes that fires
/// synchronously on the calling thread.
/// </summary>
public interface IBackgroundPropertyChangedNotification
{
/// <summary>
/// Occurs when the value of a property changes.
/// </summary>
event PropertyChangedEventHandler? PropertyChangedBackground;
}

View File

@@ -77,11 +77,11 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
public IconInfoViewModel Icon { get; protected set; }
public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost)
: base((IPageContext?)null)
: base(scheduler)
{
InitializeSelfAsPageContext();
_pageModel = new(model);
Scheduler = scheduler;
PageContext = new(this);
ExtensionHost = extensionHost;
Icon = new(null);

View File

@@ -43,4 +43,10 @@
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>$(AssemblyName).UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -6,6 +6,7 @@ using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
@@ -23,6 +24,8 @@ public sealed class CommandProviderWrapper
private readonly TaskScheduler _taskScheduler;
private readonly ICommandProviderCache? _commandProviderCache;
public TopLevelViewModel[] TopLevelItems { get; private set; } = [];
public TopLevelViewModel[] FallbackItems { get; private set; } = [];
@@ -43,13 +46,7 @@ public sealed class CommandProviderWrapper
public bool IsActive { get; private set; }
public string ProviderId
{
get
{
return string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
}
}
public string ProviderId => string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread)
{
@@ -77,9 +74,11 @@ public sealed class CommandProviderWrapper
Logger.LogDebug($"Initialized command provider {ProviderId}");
}
public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread)
public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread, ICommandProviderCache commandProviderCache)
{
_taskScheduler = mainThread;
_commandProviderCache = commandProviderCache;
Extension = extension;
ExtensionHost = new CommandPaletteHost(extension);
if (!Extension.IsRunning())
@@ -128,30 +127,31 @@ public sealed class CommandProviderWrapper
if (!isValid)
{
IsActive = false;
RecallFromCache();
return;
}
var settings = serviceProvider.GetService<SettingsModel>()!;
IsActive = GetProviderSettings(settings).IsEnabled;
var providerSettings = GetProviderSettings(settings);
IsActive = providerSettings.IsEnabled;
if (!IsActive)
{
RecallFromCache();
return;
}
ICommandItem[]? commands = null;
IFallbackCommandItem[]? fallbacks = null;
var displayInfoInitialized = false;
try
{
var model = _commandProvider.Unsafe!;
Task<ICommandItem[]> t = new(model.TopLevelCommands);
t.Start();
commands = await t.ConfigureAwait(false);
Task<ICommandItem[]> loadTopLevelCommandsTask = new(model.TopLevelCommands);
loadTopLevelCommandsTask.Start();
var commands = await loadTopLevelCommandsTask.ConfigureAwait(false);
// On a BG thread here
fallbacks = model.FallbackCommands();
var fallbacks = model.FallbackCommands();
if (model is ICommandProvider2 two)
{
@@ -162,6 +162,13 @@ public sealed class CommandProviderWrapper
DisplayName = model.DisplayName;
Icon = new(model.Icon);
Icon.InitializeProperties();
displayInfoInitialized = true;
// Update cached display name
if (_commandProviderCache is not null && Extension?.ExtensionUniqueId is not null)
{
_commandProviderCache.Memorize(Extension.ExtensionUniqueId, new CommandProviderCacheItem(model.DisplayName));
}
// Note: explicitly not InitializeProperties()ing the settings here. If
// we do that, then we'd regress GH #38321
@@ -177,6 +184,25 @@ public sealed class CommandProviderWrapper
Logger.LogError("Failed to load commands from extension");
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
Logger.LogError(e.ToString());
if (!displayInfoInitialized)
{
RecallFromCache();
}
}
}
private void RecallFromCache()
{
var cached = _commandProviderCache?.Recall(ProviderId);
if (cached is not null)
{
DisplayName = cached.DisplayName;
}
if (string.IsNullOrWhiteSpace(DisplayName))
{
DisplayName = Extension?.PackageDisplayName ?? Extension?.PackageFamilyName ?? ProviderId;
}
}
@@ -185,7 +211,7 @@ public sealed class CommandProviderWrapper
var settings = serviceProvider.GetService<SettingsModel>()!;
var providerSettings = GetProviderSettings(settings);
Func<ICommandItem?, bool, TopLevelViewModel> makeAndAdd = (ICommandItem? i, bool fallback) =>
var makeAndAdd = (ICommandItem? i, bool fallback) =>
{
CommandItemViewModel commandItemViewModel = new(new(i), pageContext);
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ProviderId, settings, providerSettings, serviceProvider, i);

View File

@@ -19,7 +19,7 @@ public partial class OpenSettingsCommand : InvokableCommand
public override ICommandResult Invoke()
{
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
return CommandResult.KeepOpen();
}
}

View File

@@ -4,6 +4,4 @@
namespace Microsoft.CmdPal.UI.Messages;
public record OpenSettingsMessage()
{
}
public record OpenSettingsMessage(string SettingsPageTag = "");

View File

@@ -14,11 +14,13 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ProviderSettingsViewModel : ObservableObject
{
private static readonly IconInfoViewModel EmptyIcon = new(null);
private readonly CommandProviderWrapper _provider;
private readonly ProviderSettings _providerSettings;
private readonly SettingsModel _settings;
private readonly Lock _initializeSettingsLock = new();
private Task? _initializeSettingsTask;
public ProviderSettingsViewModel(
@@ -43,7 +45,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
HasFallbackCommands ?
$"{ExtensionName}, {TopLevelCommands.Count} commands, {_provider.FallbackItems?.Length} fallback commands" :
$"{ExtensionName}, {TopLevelCommands.Count} commands" :
Resources.builtin_disabled_extension;
$"{ExtensionName}, {Resources.builtin_disabled_extension}";
[MemberNotNullWhen(true, nameof(Extension))]
public bool IsFromExtension => _provider.Extension is not null;
@@ -52,7 +54,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
public string ExtensionVersion => IsFromExtension ? $"{Extension.Version.Major}.{Extension.Version.Minor}.{Extension.Version.Build}.{Extension.Version.Revision}" : string.Empty;
public IconInfoViewModel Icon => _provider.Icon;
public IconInfoViewModel Icon => IsEnabled ? _provider.Icon : EmptyIcon;
[ObservableProperty]
public partial bool LoadingSettings { get; set; }
@@ -69,6 +71,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
OnPropertyChanged(nameof(IsEnabled));
OnPropertyChanged(nameof(ExtensionSubtext));
OnPropertyChanged(nameof(Icon));
}
if (value == true)

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Services;
internal sealed class CommandProviderCacheContainer
{
public Dictionary<string, CommandProviderCacheItem> Cache { get; init; } = new(StringComparer.Ordinal);
}

View File

@@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Services;
public record CommandProviderCacheItem(string DisplayName);

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
[JsonSerializable(typeof(CommandProviderCacheItem))]
[JsonSerializable(typeof(Dictionary<string, CommandProviderCacheItem>))]
[JsonSerializable(typeof(CommandProviderCacheContainer))]
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNameCaseInsensitive = false)]
internal sealed partial class CommandProviderCacheSerializationContext : JsonSerializerContext;

View File

@@ -0,0 +1,127 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
public sealed partial class DefaultCommandProviderCache : ICommandProviderCache, IDisposable
{
private const string CacheFileName = "commandProviderCache.json";
private readonly Dictionary<string, CommandProviderCacheItem> _cache = new(StringComparer.Ordinal);
private readonly Lock _sync = new();
private readonly SupersedingAsyncGate _saveGate;
public DefaultCommandProviderCache()
{
_saveGate = new SupersedingAsyncGate(async _ => await TrySaveAsync().ConfigureAwait(false));
TryLoad();
}
public void Memorize(string providerId, CommandProviderCacheItem item)
{
ArgumentNullException.ThrowIfNull(providerId);
lock (_sync)
{
_cache[providerId] = item;
}
_ = _saveGate.ExecuteAsync();
}
public CommandProviderCacheItem? Recall(string providerId)
{
ArgumentNullException.ThrowIfNull(providerId);
lock (_sync)
{
_cache.TryGetValue(providerId, out var item);
return item;
}
}
private static string GetCacheFilePath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
return Path.Combine(directory, CacheFileName);
}
private void TryLoad()
{
try
{
var path = GetCacheFilePath();
if (!File.Exists(path))
{
return;
}
var json = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(json))
{
return;
}
var loaded = JsonSerializer.Deserialize(
json,
CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!);
if (loaded?.Cache is null)
{
return;
}
_cache.Clear();
foreach (var kvp in loaded.Cache)
{
if (!string.IsNullOrEmpty(kvp.Key) && kvp.Value is not null)
{
_cache[kvp.Key] = kvp.Value;
}
}
}
catch (Exception ex)
{
Logger.LogError("Failed to load command provider cache: ", ex);
}
}
private async Task TrySaveAsync()
{
try
{
Dictionary<string, CommandProviderCacheItem> snapshot;
lock (_sync)
{
snapshot = new Dictionary<string, CommandProviderCacheItem>(_cache, StringComparer.Ordinal);
}
var container = new CommandProviderCacheContainer
{
Cache = snapshot,
};
var path = GetCacheFilePath();
var json = JsonSerializer.Serialize(container, CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!);
await File.WriteAllTextAsync(path, json).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogError("Failed to save command provider cache: ", ex);
}
}
public void Dispose()
{
_saveGate.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Services;
public interface ICommandProviderCache
{
void Memorize(string providerId, CommandProviderCacheItem item);
CommandProviderCacheItem? Recall(string providerId);
}

View File

@@ -13,6 +13,7 @@ using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
@@ -25,6 +26,7 @@ public partial class TopLevelCommandManager : ObservableObject,
IDisposable
{
private readonly IServiceProvider _serviceProvider;
private readonly ICommandProviderCache _commandProviderCache;
private readonly TaskScheduler _taskScheduler;
private readonly List<CommandProviderWrapper> _builtInCommands = [];
@@ -34,9 +36,10 @@ public partial class TopLevelCommandManager : ObservableObject,
TaskScheduler IPageContext.Scheduler => _taskScheduler;
public TopLevelCommandManager(IServiceProvider serviceProvider)
public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache)
{
_serviceProvider = serviceProvider;
_commandProviderCache = commandProviderCache;
_taskScheduler = _serviceProvider.GetService<TaskScheduler>()!;
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
@@ -319,7 +322,7 @@ public partial class TopLevelCommandManager : ObservableObject,
try
{
await extension.StartExtensionAsync().WaitAsync(TimeSpan.FromSeconds(10));
return new CommandProviderWrapper(extension, _taskScheduler);
return new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache);
}
catch (Exception ex)
{

View File

@@ -199,7 +199,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
_fallbackId = fallback.Id;
}
item.PropertyChanged += Item_PropertyChanged;
item.PropertyChangedBackground += Item_PropertyChanged;
// UpdateAlias();
// UpdateHotkey();

View File

@@ -41,7 +41,7 @@ namespace Microsoft.CmdPal.UI;
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : Application
public partial class App : Application, IDisposable
{
private readonly GlobalErrorHandler _globalErrorHandler = new();
@@ -67,7 +67,7 @@ public partial class App : Application
public App()
{
#if !CMDPAL_DISABLE_GLOBAL_ERROR_HANDLER
_globalErrorHandler.Register(this);
_globalErrorHandler.Register(this, GlobalErrorHandler.Options.Default);
#endif
Services = ConfigureServices();
@@ -178,6 +178,7 @@ public partial class App : Application
services.AddSingleton(state);
// Services
services.AddSingleton<ICommandProviderCache, DefaultCommandProviderCache>();
services.AddSingleton<TopLevelCommandManager>();
services.AddSingleton<AliasManager>();
services.AddSingleton<HotkeyManager>();
@@ -203,4 +204,11 @@ public partial class App : Application
services.AddSingleton<ShellViewModel>();
services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>();
}
public void Dispose()
{
_globalErrorHandler.Dispose();
EtwTrace.Dispose();
GC.SuppressFinalize(this);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -128,7 +128,7 @@ public sealed partial class CommandBar : UserControl,
private void SettingsIcon_Clicked(object sender, RoutedEventArgs e)
{
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
}
private void MoreCommandsButton_Clicked(object sender, RoutedEventArgs e)

View File

@@ -203,6 +203,12 @@
</Grid>
</Border>
<!-- More section -->
<TextBlock Style="{ThemeResource SettingsSectionHeaderTextBlockStyle}" Text="More" />
<Border>
<Button Command="{x:Bind ViewModel.OpenInternalToolsCommand}" Content="Open internal tools" />
</Border>
</StackPanel>
<!-- Footer -->

View File

@@ -3,7 +3,7 @@
// See the LICENSE file in the project root for more information.
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Services.Reports;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;
@@ -15,14 +15,22 @@ namespace Microsoft.CmdPal.UI.Helpers;
/// <summary>
/// Global error handler for Command Palette.
/// </summary>
internal sealed partial class GlobalErrorHandler
internal sealed partial class GlobalErrorHandler : IDisposable
{
private readonly ErrorReportBuilder _errorReportBuilder = new();
private Options? _options;
private App? _app;
// GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available.
internal void Register(App app)
internal void Register(App app, Options options)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(options);
app.UnhandledException += App_UnhandledException;
_options = options;
_app = app;
_app.UnhandledException += App_UnhandledException;
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
}
@@ -54,21 +62,15 @@ internal sealed partial class GlobalErrorHandler
HandleException(e.Exception, Context.UnobservedTaskException);
}
private static void HandleException(Exception ex, Context context)
private void HandleException(Exception ex, Context context)
{
Logger.LogError($"Unhandled exception detected ({context})", ex);
if (context == Context.MainThreadException)
{
var error = DiagnosticsHelper.BuildExceptionMessage(ex, null);
var report = $"""
This is an error report generated by Windows Command Palette.
If you are seeing this message, it means the application has encountered an unexpected issue.
You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
{error}
""";
var report = _errorReportBuilder.BuildReport(ex, context.ToString(), _options?.RedactPii ?? true);
StoreReport(report, storeOnDesktop: false);
StoreReport(report, storeOnDesktop: _options?.StoreReportOnUserDesktop == true);
string message;
string caption;
@@ -138,6 +140,13 @@ internal sealed partial class GlobalErrorHandler
}
}
public void Dispose()
{
_app?.UnhandledException -= App_UnhandledException;
TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException;
AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
}
private enum Context
{
Unknown = 0,
@@ -146,4 +155,26 @@ internal sealed partial class GlobalErrorHandler
UnobservedTaskException,
AppDomainUnhandledException,
}
/// <summary>
/// Configuration options controlling how <see cref="GlobalErrorHandler"/> reacts to exceptions
/// (what to log, what to show to the user, and where to store reports).
/// </summary>
internal sealed record Options
{
/// <summary>
/// Gets the default configuration.
/// </summary>
public static Options Default { get; } = new();
/// <summary>
/// Gets a value indicating whether Personally Identifiable Information (PII) should be redacted in error reports.
/// </summary>
public bool RedactPii { get; init; } = true;
/// <summary>
/// Gets a value indicating whether to store the error report on the user's desktop in addition to the log directory.
/// </summary>
public bool StoreReportOnUserDesktop { get; init; }
}
}

View File

@@ -152,7 +152,7 @@ internal sealed partial class TrayIconService
{
if (wParam == PInvoke.WM_USER + 1)
{
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
}
else if (wParam == PInvoke.WM_USER + 2)
{

View File

@@ -0,0 +1,152 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI;
using Microsoft.UI.Windowing;
using Windows.Graphics;
using Windows.Win32;
using Windows.Win32.Graphics.Gdi;
using Windows.Win32.UI.HiDpi;
namespace Microsoft.CmdPal.UI.Helpers;
internal static class WindowPositionHelper
{
private const int DefaultWidth = 800;
private const int DefaultHeight = 480;
private const int MinimumVisibleSize = 100;
private const int DefaultDpi = 96;
public static PointInt32? CalculateCenteredPosition(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi)
{
if (displayArea is null)
{
return null;
}
var workArea = displayArea.WorkArea;
if (workArea.Width <= 0 || workArea.Height <= 0)
{
return null;
}
var targetDpi = GetDpiForDisplay(displayArea);
var predictedSize = ScaleSize(windowSize, windowDpi, targetDpi);
// Clamp to work area
var width = Math.Min(predictedSize.Width, workArea.Width);
var height = Math.Min(predictedSize.Height, workArea.Height);
return new PointInt32(
workArea.X + ((workArea.Width - width) / 2),
workArea.Y + ((workArea.Height - height) / 2));
}
/// <summary>
/// Adjusts a saved window rect to ensure it's visible on the nearest display,
/// accounting for DPI changes and work area differences.
/// </summary>
///
public static RectInt32 AdjustRectForVisibility(RectInt32 savedRect, SizeInt32 savedScreenSize, int savedDpi)
{
var displayArea = DisplayArea.GetFromRect(savedRect, DisplayAreaFallback.Nearest);
if (displayArea is null)
{
return savedRect;
}
var workArea = displayArea.WorkArea;
if (workArea.Width <= 0 || workArea.Height <= 0)
{
return savedRect;
}
var targetDpi = GetDpiForDisplay(displayArea);
if (savedDpi <= 0)
{
savedDpi = targetDpi;
}
var hasInvalidSize = savedRect.Width <= 0 || savedRect.Height <= 0;
if (hasInvalidSize)
{
savedRect = savedRect with { Width = DefaultWidth, Height = DefaultHeight };
}
if (targetDpi != savedDpi)
{
savedRect = ScaleRect(savedRect, savedDpi, targetDpi);
}
var clampedSize = ClampSize(savedRect.Width, savedRect.Height, workArea);
var shouldRecenter = hasInvalidSize ||
IsOffscreen(savedRect, workArea) ||
savedScreenSize.Width != workArea.Width ||
savedScreenSize.Height != workArea.Height;
if (shouldRecenter)
{
return CenterRectInWorkArea(clampedSize, workArea);
}
return new RectInt32(savedRect.X, savedRect.Y, clampedSize.Width, clampedSize.Height);
}
private static int GetDpiForDisplay(DisplayArea displayArea)
{
var hMonitor = Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId);
if (hMonitor == IntPtr.Zero)
{
return DefaultDpi;
}
var hr = PInvoke.GetDpiForMonitor(
new HMONITOR(hMonitor),
MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI,
out var dpiX,
out _);
return hr.Succeeded && dpiX > 0 ? (int)dpiX : DefaultDpi;
}
private static SizeInt32 ScaleSize(SizeInt32 size, int fromDpi, int toDpi)
{
if (fromDpi <= 0 || toDpi <= 0 || fromDpi == toDpi)
{
return size;
}
var scale = (double)toDpi / fromDpi;
return new SizeInt32(
(int)Math.Round(size.Width * scale),
(int)Math.Round(size.Height * scale));
}
private static RectInt32 ScaleRect(RectInt32 rect, int fromDpi, int toDpi)
{
var scale = (double)toDpi / fromDpi;
return new RectInt32(
(int)Math.Round(rect.X * scale),
(int)Math.Round(rect.Y * scale),
(int)Math.Round(rect.Width * scale),
(int)Math.Round(rect.Height * scale));
}
private static SizeInt32 ClampSize(int width, int height, RectInt32 workArea) =>
new(Math.Min(width, workArea.Width), Math.Min(height, workArea.Height));
private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea) =>
new(
workArea.X + ((workArea.Width - size.Width) / 2),
workArea.Y + ((workArea.Height - size.Height) / 2),
size.Width,
size.Height);
private static bool IsOffscreen(RectInt32 rect, RectInt32 workArea) =>
rect.X + MinimumVisibleSize > workArea.X + workArea.Width ||
rect.X + rect.Width - MinimumVisibleSize < workArea.X ||
rect.Y + MinimumVisibleSize > workArea.Y + workArea.Height ||
rect.Y + rect.Height - MinimumVisibleSize < workArea.Y;
}

View File

@@ -21,7 +21,6 @@ using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI;
using Microsoft.UI.Composition;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Input;
@@ -32,13 +31,9 @@ using Windows.ApplicationModel.Activation;
using Windows.Foundation;
using Windows.Graphics;
using Windows.System;
using Windows.UI;
using Windows.UI.WindowManagement;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Dwm;
using Windows.Win32.Graphics.Gdi;
using Windows.Win32.UI.HiDpi;
using Windows.Win32.UI.Input.KeyboardAndMouse;
using Windows.Win32.UI.WindowsAndMessaging;
using WinRT;
@@ -60,9 +55,6 @@ public sealed partial class MainWindow : WindowEx,
IRecipient<DragCompletedMessage>,
IDisposable
{
private const int DefaultWidth = 800;
private const int DefaultHeight = 480;
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")]
private readonly uint WM_TASKBAR_RESTART;
@@ -226,39 +218,40 @@ public sealed partial class MainWindow : WindowEx,
PositionCentered(displayArea);
}
private void PositionCentered(DisplayArea displayArea)
{
var position = WindowPositionHelper.CalculateCenteredPosition(
displayArea,
AppWindow.Size,
(int)this.GetDpiForWindow());
if (position is not null)
{
// Use Move(), not MoveAndResize(). Windows auto-resizes on DPI change via WM_DPICHANGED;
// the helper already accounts for this when calculating the centered position.
AppWindow.Move((PointInt32)position);
}
}
private void RestoreWindowPosition()
{
var settings = App.Current.Services.GetService<SettingsModel>();
if (settings?.LastWindowPosition is not WindowPosition savedPosition)
if (settings?.LastWindowPosition is not { Width: > 0, Height: > 0 } savedPosition)
{
PositionCentered();
return;
}
if (savedPosition.Width <= 0 || savedPosition.Height <= 0)
{
PositionCentered();
return;
}
// MoveAndResize is safe here—we're restoring a saved state at startup,
// not moving a live window between displays.
var newRect = WindowPositionHelper.AdjustRectForVisibility(
savedPosition.ToPhysicalWindowRectangle(),
new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight),
savedPosition.Dpi);
var newRect = EnsureWindowIsVisible(savedPosition.ToPhysicalWindowRectangle(), new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight), savedPosition.Dpi);
AppWindow.MoveAndResize(newRect);
}
private void PositionCentered(DisplayArea displayArea)
{
if (displayArea is not null)
{
var centeredPosition = AppWindow.Position;
centeredPosition.X = (displayArea.WorkArea.Width - AppWindow.Size.Width) / 2;
centeredPosition.Y = (displayArea.WorkArea.Height - AppWindow.Size.Height) / 2;
centeredPosition.X += displayArea.WorkArea.X;
centeredPosition.Y += displayArea.WorkArea.Y;
AppWindow.Move(centeredPosition);
}
}
private void UpdateWindowPositionInMemory()
{
var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary;
@@ -352,7 +345,8 @@ public sealed partial class MainWindow : WindowEx,
if (target == MonitorBehavior.ToLast)
{
var newRect = EnsureWindowIsVisible(_currentWindowPosition.ToPhysicalWindowRectangle(), new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight), _currentWindowPosition.Dpi);
var originalScreen = new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight);
var newRect = WindowPositionHelper.AdjustRectForVisibility(_currentWindowPosition.ToPhysicalWindowRectangle(), originalScreen, _currentWindowPosition.Dpi);
AppWindow.MoveAndResize(newRect);
}
else
@@ -382,115 +376,7 @@ public sealed partial class MainWindow : WindowEx,
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
}
/// <summary>
/// Ensures that the window rectangle is visible on-screen.
/// </summary>
/// <param name="windowRect">The window rectangle in physical pixels.</param>
/// <param name="originalScreen">The desktop area the window was positioned on.</param>
/// <param name="originalDpi">The window's original DPI.</param>
/// <returns>
/// A window rectangle in physical pixels, moved to the nearest display and resized
/// if the DPI has changed.
/// </returns>
private static RectInt32 EnsureWindowIsVisible(RectInt32 windowRect, SizeInt32 originalScreen, int originalDpi)
{
var displayArea = DisplayArea.GetFromRect(windowRect, DisplayAreaFallback.Nearest);
if (displayArea is null)
{
return windowRect;
}
var workArea = displayArea.WorkArea;
if (workArea.Width <= 0 || workArea.Height <= 0)
{
// Fallback, nothing reasonable to do
return windowRect;
}
var effectiveDpi = GetEffectiveDpiFromDisplayId(displayArea);
if (originalDpi <= 0)
{
originalDpi = effectiveDpi; // use current DPI as baseline (no scaling adjustment needed)
}
var hasInvalidSize = windowRect.Width <= 0 || windowRect.Height <= 0;
if (hasInvalidSize)
{
windowRect = new RectInt32(windowRect.X, windowRect.Y, DefaultWidth, DefaultHeight);
}
// If we have a DPI change, scale the window rectangle accordingly
if (effectiveDpi != originalDpi)
{
var scalingFactor = effectiveDpi / (double)originalDpi;
windowRect = new RectInt32(
(int)Math.Round(windowRect.X * scalingFactor),
(int)Math.Round(windowRect.Y * scalingFactor),
(int)Math.Round(windowRect.Width * scalingFactor),
(int)Math.Round(windowRect.Height * scalingFactor));
}
var targetWidth = Math.Min(windowRect.Width, workArea.Width);
var targetHeight = Math.Min(windowRect.Height, workArea.Height);
// Ensure at least some minimum visible area (e.g., 100 pixels)
// This helps prevent the window from being entirely offscreen, regardless of display scaling.
const int minimumVisibleSize = 100;
var isOffscreen =
windowRect.X + minimumVisibleSize > workArea.X + workArea.Width ||
windowRect.X + windowRect.Width - minimumVisibleSize < workArea.X ||
windowRect.Y + minimumVisibleSize > workArea.Y + workArea.Height ||
windowRect.Y + windowRect.Height - minimumVisibleSize < workArea.Y;
// if the work area size has changed, re-center the window
var workAreaSizeChanged =
originalScreen.Width != workArea.Width ||
originalScreen.Height != workArea.Height;
int targetX;
int targetY;
var recenter = isOffscreen || workAreaSizeChanged || hasInvalidSize;
if (recenter)
{
targetX = workArea.X + ((workArea.Width - targetWidth) / 2);
targetY = workArea.Y + ((workArea.Height - targetHeight) / 2);
}
else
{
targetX = windowRect.X;
targetY = windowRect.Y;
}
return new RectInt32(targetX, targetY, targetWidth, targetHeight);
}
private static int GetEffectiveDpiFromDisplayId(DisplayArea displayArea)
{
var effectiveDpi = 96;
var hMonitor = (HMONITOR)Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId);
if (!hMonitor.IsNull)
{
var hr = PInvoke.GetDpiForMonitor(hMonitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out var dpiX, out _);
if (hr == 0)
{
effectiveDpi = (int)dpiX;
}
else
{
Logger.LogWarning($"GetDpiForMonitor failed with HRESULT: 0x{hr.Value:X8} on display {displayArea.DisplayId}");
}
}
if (effectiveDpi <= 0)
{
effectiveDpi = 96;
}
return effectiveDpi;
}
private DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
{
// Leaving a note here, in case we ever need it:
// https://github.com/microsoft/microsoft-ui-xaml/issues/6454

View File

@@ -83,6 +83,7 @@
<None Remove="Pages\Settings\GeneralPage.xaml" />
<None Remove="SettingsWindow.xaml" />
<None Remove="Settings\AppearancePage.xaml" />
<None Remove="Settings\InternalPage.xaml" />
<None Remove="ShellPage.xaml" />
<None Remove="Styles\Colors.xaml" />
<None Remove="Styles\Settings.xaml" />
@@ -264,6 +265,11 @@
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="Settings\InternalPage.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="Styles\Colors.xaml">
<Generator>MSBuild:Compile</Generator>

View File

@@ -257,11 +257,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
{
_ = DispatcherQueue.TryEnqueue(() =>
{
OpenSettings();
OpenSettings(message.SettingsPageTag);
});
}
public void OpenSettings()
public void OpenSettings(string pageTag)
{
if (_settingsWindow is null)
{
@@ -270,6 +270,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_settingsWindow.Activate();
_settingsWindow.BringToFront();
_settingsWindow.Navigate(pageTag);
}
public void Receive(ShowDetailsMessage message)

View File

@@ -229,12 +229,26 @@
<controls:SettingsCard.HeaderIcon>
<cpcontrols:ContentIcon>
<cpcontrols:ContentIcon.Content>
<cpcontrols:IconBox
Width="20"
Height="20"
<controls:SwitchPresenter
AutomationProperties.AccessibilityView="Raw"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
TargetType="x:Boolean"
Value="{x:Bind Icon.IsSet, FallbackValue=x:False, Mode=OneWay}">
<controls:Case Value="True">
<cpcontrols:IconBox
Width="20"
Height="20"
AutomationProperties.AccessibilityView="Raw"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
</controls:Case>
<controls:Case Value="False">
<Image
Width="20"
Height="20"
AutomationProperties.AccessibilityView="Raw"
Source="ms-appx:///Assets/Icons/ExtensionIconPlaceholder.png" />
</controls:Case>
</controls:SwitchPresenter>
</cpcontrols:ContentIcon.Content>
</cpcontrols:ContentIcon>
</controls:SettingsCard.HeaderIcon>

View File

@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.Settings;
public partial class InternalPage
{
internal static class SampleData
{
internal static string ExceptionMessageWithPii { get; } =
$"""
Test exception with personal information; thrown from the UI thread
Here is e-mail address <jane.doe@contoso.com>
IPv4 address: 192.168.100.1
IPv4 loopback address: 127.0.0.1
MAC address: 00-14-22-01-23-45
IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
IPv6 loopback address: ::1
Password: P@ssw0rd123!
Password=secret
Api key: 1234567890abcdef
PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb
InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com;
X-API-key: 1234567890abcdef
Pet-Shop-Subscription-Key: 1234567890abcdef
Here is a user name {Environment.UserName}
And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\Pictures
Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal
Here is machine name {Environment.MachineName}
JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
User email john.doe@company.com failed validation
File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt
Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test
Phone number 555-123-4567 is invalid
API key abc123def456ghi789jkl012mno345pqr678 expired
Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123
Error accessing file://C:/Users/john.doe/Documents/confidential.pdf
JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret
FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv
Email service error: mailto:admin@internal-company.com?subject=Alert
""";
}
}

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Microsoft.CmdPal.UI.Settings.InternalPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:CommunityToolkit.WinUI"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="1">
<Grid Padding="16">
<StackPanel
MaxWidth="1000"
HorizontalAlignment="Stretch"
Spacing="{StaticResource SettingsCardSpacing}">
<TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="Tools on this page are for internal use only. This page is not visible in CI builds." />
<!-- Exception Handling Section -->
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Exception Handling" />
<controls:SettingsExpander
Description="Actions for testing global exception handling from the application"
Header="Throw exceptions"
HeaderIcon="{ui:FontIcon Glyph=&#xE783;}"
IsExpanded="True">
<controls:SettingsExpander.Items>
<controls:SettingsCard Header="Throw an unhandled exception from the UI thread">
<Button Click="ThrowPlainMainThreadException_Click" Content="Throw" />
</controls:SettingsCard>
<controls:SettingsCard Header="Throw an unhandled exception from the UI thread (with PII)">
<Button Click="ThrowPlainMainThreadExceptionPii_Click" Content="Throw" />
</controls:SettingsCard>
<controls:SettingsCard Description="Throw with delay, when the task is collected by the GC" Header="Throw unobserved exception from a task">
<Button Click="ThrowExceptionInUnobservedTask_Click" Content="Throw" />
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
<!-- Diagnostics Section -->
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Diagnostics" />
<controls:SettingsCard
x:Name="LogsSettingsCard"
Header="Logs folder"
HeaderIcon="{ui:FontIcon Glyph=&#xE8B7;}">
<Button Click="OpenLogsCardClicked" Content="Open folder" />
</controls:SettingsCard>
<controls:SettingsCard
x:Name="CurrentLogFileSettingsCard"
Header="Current log file"
HeaderIcon="{ui:FontIcon Glyph=&#xF7BB;}">
<Button Click="OpenCurrentLogCardClicked" Content="Open log" />
</controls:SettingsCard>
<!-- Data Section -->
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Data and Files" />
<controls:SettingsCard
x:Name="ConfigurationFolderSettingsCard"
Header="Configuration folder"
HeaderIcon="{ui:FontIcon Glyph=&#xF73D;}">
<Button Click="OpenConfigFolderCardClick" Content="Open folder" />
</controls:SettingsCard>
</StackPanel>
</Grid>
</ScrollViewer>
</Grid>
</Page>

View File

@@ -0,0 +1,92 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using ManagedCommon;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.UI.Xaml;
using Windows.System;
using Page = Microsoft.UI.Xaml.Controls.Page;
namespace Microsoft.CmdPal.UI.Settings;
/// <summary>
/// An empty page that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class InternalPage : Page
{
public InternalPage()
{
InitializeComponent();
}
private void ThrowPlainMainThreadException_Click(object sender, RoutedEventArgs e)
{
Logger.LogDebug("Throwing test exception from the UI thread");
throw new NotImplementedException("Test exception; thrown from the UI thread");
}
private void ThrowExceptionInUnobservedTask_Click(object sender, RoutedEventArgs e)
{
Logger.LogDebug("Starting a task that will throw test exception");
Task.Run(() =>
{
Logger.LogDebug("Throwing test exception from a task");
throw new InvalidOperationException("Test exception; thrown from a task");
});
}
private void ThrowPlainMainThreadExceptionPii_Click(object sender, RoutedEventArgs e)
{
Logger.LogDebug("Throwing test exception from the UI thread (PII)");
throw new InvalidOperationException(SampleData.ExceptionMessageWithPii);
}
private async void OpenLogsCardClicked(object sender, RoutedEventArgs e)
{
try
{
var logFolderPath = Logger.CurrentVersionLogDirectoryPath;
if (Directory.Exists(logFolderPath))
{
await Launcher.LaunchFolderPathAsync(logFolderPath);
}
}
catch (Exception ex)
{
Logger.LogError("Failed to open directory in Explorer", ex);
}
}
private async void OpenCurrentLogCardClicked(object sender, RoutedEventArgs e)
{
try
{
var logPath = Logger.CurrentLogFile;
if (File.Exists(logPath))
{
await Launcher.LaunchUriAsync(new Uri(logPath));
}
}
catch (Exception ex)
{
Logger.LogError("Failed to open log file", ex);
}
}
private async void OpenConfigFolderCardClick(object sender, RoutedEventArgs e)
{
try
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
if (Directory.Exists(directory))
{
await Launcher.LaunchFolderPathAsync(directory);
}
}
catch (Exception ex)
{
Logger.LogError("Failed to open directory in Explorer", ex);
}
}
}

Some files were not shown because too many files have changed in this diff Show More