From 2a8015fd0c898a06a0acdaba6071555ecfaff236 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Sat, 13 Jun 2026 00:45:47 +0000 Subject: [PATCH 01/25] ruff --- ruff.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ruff.toml b/ruff.toml index 2dbdc60d8..d10ebf444 100644 --- a/ruff.toml +++ b/ruff.toml @@ -59,11 +59,12 @@ ignore = [ "N999", # Ignoring this since we are using jinja templates in the directory path which will eventually turn into a valid module name. # TODO: make a task in downstream templates to remove this "S101", # We do not use the -o optimize flag, so it's fine to use `assert` in the main code. It's especially helpful for static typing "SIM102", # Nested if statements can be better analyzed by coverage detectors than compound statements + "SIM108", # Explicit if/else branches let the coverage checker track both paths, unlike a ternary + "SIM114", # Using `or` statements to simplify this would confuse the coverage checker "TD002", # Adding author names to TODOs erodes shared ownership of codebase. Git history provides information about who originally created the TODO if that information is vitally needed "TD003", # Up to the author's judgement whether a TODO requires a link to an issue or not "TID252", # Sometimes it makes sense to use relative imports, that's a judgement call for us, not ruff "TC006", # Adding quotes around classes unnecessarily confuses the IDE for automatic refactoring - "SIM114", # Using `or` statements to simplify this would confuse the coverage checker ] # Allow fix for all enabled rules (when `--fix`) is provided. From 407bd323d7e6c748901d086de17f87b5bebbc83b Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Sat, 13 Jun 2026 00:47:40 +0000 Subject: [PATCH 02/25] workspace --- copier-base-template.code-workspace | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 copier-base-template.code-workspace diff --git a/copier-base-template.code-workspace b/copier-base-template.code-workspace new file mode 100644 index 000000000..9e186d54c --- /dev/null +++ b/copier-base-template.code-workspace @@ -0,0 +1,25 @@ +{ + "folders": [ + { + "name": "root", + "path": ".", + }, + { + "name": "child", + "path": "template", + }, + { + "name": "grandchild", + "path": "template/template", + }, + ], + "settings": { + // Shows the workspace name in the status bar + "workbench.editor.labelFormat": "medium", + + "files.exclude": { + // Excluding service dirs since they are imported as their own workspace folders + "template": true, + }, + }, +} From 4192c1058d84d6e353d122b5c5e2fa5e1f672780 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 15 Jun 2026 13:44:37 +0000 Subject: [PATCH 03/25] bump faker --- .devcontainer/devcontainer.json | 2 +- extensions/context.py | 4 ++-- pyproject.toml | 2 +- uv.lock | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index caec43db4..d6132b659 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -68,5 +68,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 623742bd # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 85e84046 # spellchecker:disable-line } diff --git a/extensions/context.py b/extensions/context.py index 41f208ae4..6c97c0d55 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -44,7 +44,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["pyinstaller_version"] = ">=6.20.0" context["setuptools_version"] = "80.7.1" context["strawberry_graphql_version"] = ">=0.298.0" - context["fastapi_version"] = ">=0.136.3" + context["fastapi_version"] = ">=0.137.1" context["fastapi_offline_version"] = ">=1.7.4" context["uvicorn_version"] = ">=0.49.0" context["lab_auto_pulumi_version"] = ">=0.2.3" @@ -61,7 +61,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["pytest_asyncio_version"] = ">=1.4.0" context["pytest_timeout_version"] = ">=2.4.0" context["pytest_reserial_version"] = ">=0.6.1" - context["python_faker_version"] = ">=40.22.0" + context["python_faker_version"] = ">=40.23.0" ####### context["nuxt_ui_version"] = "^4.8.1" context["nuxt_version"] = "^4.4.6" diff --git a/pyproject.toml b/pyproject.toml index 90cdcba2a..7aa8c877a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "pytest>=9.0.3", "pytest-cov>=7.1.0", "pytest-randomly>=4.1.0", - "faker>=40.22.0", + "faker>=40.23.0", "pyright[nodejs]>=1.1.410", "pyrefly>=1.0.0", "mutmut>=3.6.0", diff --git a/uv.lock b/uv.lock index 8e56104af..62cde7f38 100644 --- a/uv.lock +++ b/uv.lock @@ -82,7 +82,7 @@ dependencies = [ requires-dist = [ { name = "copier", specifier = "==9.15.1" }, { name = "copier-template-extensions", specifier = "==0.3.3" }, - { name = "faker", specifier = ">=40.22.0" }, + { name = "faker", specifier = ">=40.23.0" }, { name = "mutmut", specifier = ">=3.6.0" }, { name = "pyrefly", specifier = ">=1.0.0" }, { name = "pyright", extras = ["nodejs"], specifier = ">=1.1.410" }, @@ -182,14 +182,14 @@ wheels = [ [[package]] name = "faker" -version = "40.22.0" +version = "40.23.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/11/81debfd0c4f8542c746c95d0267d9dd31b8e18ec21ed7ab89569de1bbfea/faker-40.22.0.tar.gz", hash = "sha256:0df62f975c97f79be3d89d626c4ee1518604cb4fea94f7629697f0529fc757d5", size = 1972732, upload-time = "2026-06-09T22:11:57.112Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/d6/fc071e5754815d9058e12ab549cc88e90f8f4ecf4dc33b6b750cdf4b622d/faker-40.23.0.tar.gz", hash = "sha256:f135e563f1f95f19346bb680bc2e43570bc43b7893e566023746f51f32c69dfc", size = 1972975, upload-time = "2026-06-10T20:53:21.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/99/05e89cc2849df99c782edba7b0f7cd0f87f399fc7737dfa9dfc400dcdaaa/faker-40.22.0-py3-none-any.whl", hash = "sha256:d625adfc227deb316eaed6a366b7073a71228975c1524d6a52de797c7588052d", size = 2012839, upload-time = "2026-06-09T22:11:54.533Z" }, + { url = "https://files.pythonhosted.org/packages/64/5f/824e6fb3e9d63408151dc9173994fa65bde620a67dde3a59354f5aecd497/faker-40.23.0-py3-none-any.whl", hash = "sha256:775922453e54afa42eaf60eac478fa3a969357f224d09a8022b93e3ad88f18ae", size = 2013046, upload-time = "2026-06-10T20:53:19.226Z" }, ] [[package]] From 0352a2b2b70e28ec8043f9f10841e5f7ee537977 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 15 Jun 2026 13:49:06 +0000 Subject: [PATCH 04/25] pytest --- .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 20 ++++++++++++++++++++ extensions/context.py | 3 +-- pyproject.toml | 7 +++++-- uv.lock | 8 ++++---- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d6132b659..04895765f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -68,5 +68,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 85e84046 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 2110eb60 # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e502c90a0..da97ac53f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -295,6 +295,26 @@ repos: # print the number of files as a sanity-check verbose: true + - repo: local + hooks: + - id: pyrefly + name: pyrefly + entry: bash -c "uv run pyrefly check" + files: '.+\.py$' + exclude: | + (?x)^( + .*/generated/graphql/.*| + .*/generated/open[-_]api/.*| + )$ + # don't pass filenames else the command line sees them twice + pass_filenames: false + language: system + types: [python] + # use require_serial so that script is only called once per commit + require_serial: true + # print the number of files as a sanity-check + verbose: true + # Updating repo config/tooling files - repo: local hooks: diff --git a/extensions/context.py b/extensions/context.py index 6c97c0d55..2d49e7197 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -24,10 +24,9 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["nvm_version"] = "0.40.4" # These are duplicated in the pyproject.toml of this repository context["pyright_version"] = ">=1.1.410" - context["pytest_version"] = ">=9.0.3" + context["pytest_version"] = ">=9.1.0" context["pytest_randomly_version"] = ">=4.1.0" context["pytest_cov_version"] = ">=7.1.0" - context["ty_version"] = ">=0.0.40" context["mutmut_version"] = ">=3.6.0" context["pyrefly_version"] = ">=1.0.0" ####### diff --git a/pyproject.toml b/pyproject.toml index 7aa8c877a..f41dbe549 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12.7" dependencies = [ - "pytest>=9.0.3", + "pytest>=9.1.0", "pytest-cov>=7.1.0", "pytest-randomly>=4.1.0", "faker>=40.23.0", @@ -26,7 +26,10 @@ exclude-newer = "1 day" source_paths = [ "src/" ] pytest_add_cli_args_test_selection = [ "tests/unit/" ] pytest_add_cli_args = [ "--no-cov" ] -also_copy = [ ".pre-commit-config.yaml" ] +also_copy = [ + "uv.lock", # can remove when this is resolved https://github.com/boxed/mutmut/issues/529 + ".pre-commit-config.yaml" +] mutate_only_covered_lines = true do_not_mutate = [ "*/generated/*" , # do not mutate generated code, this is created by external tooling that has undergone its own testing diff --git a/uv.lock b/uv.lock index 62cde7f38..f58f2e46d 100644 --- a/uv.lock +++ b/uv.lock @@ -86,7 +86,7 @@ requires-dist = [ { name = "mutmut", specifier = ">=3.6.0" }, { name = "pyrefly", specifier = ">=1.0.0" }, { name = "pyright", extras = ["nodejs"], specifier = ">=1.1.410" }, - { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest", specifier = ">=9.1.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-randomly", specifier = ">=4.1.0" }, { name = "pyyaml", specifier = ">=6.0.3" }, @@ -575,7 +575,7 @@ nodejs = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -584,9 +584,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] [[package]] From fb7528452d4aca86b0888cab995bb5bfaa3e7bc4 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 15 Jun 2026 13:51:50 +0000 Subject: [PATCH 05/25] comment --- pyrefly.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrefly.toml b/pyrefly.toml index 2895ee2a3..82daae2ed 100644 --- a/pyrefly.toml +++ b/pyrefly.toml @@ -33,4 +33,4 @@ untyped-import = "ignore" matches = "**/tests/**" [sub-config.errors] -implicitly-defined-attribute = "ignore" +implicitly-defined-attribute = "ignore" # doesn't make any sense for test cases. See https://github.com/microsoft/pyright/discussions/4834#discussioncomment-8496931 From ed469aae4b5b00c7e31f927f343f9a8c52596cdc Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 15 Jun 2026 13:52:18 +0000 Subject: [PATCH 06/25] more pyrefly config --- pyrefly.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyrefly.toml b/pyrefly.toml index 82daae2ed..a170a4d79 100644 --- a/pyrefly.toml +++ b/pyrefly.toml @@ -24,8 +24,7 @@ spec-compliant-overloads = true # not sure exactly what this does, but seems lik ignore-errors-in-generated-code = true # not sure yet how effective this is [errors] -# reportPrivateUsage=false in pyright (already covered by ruff SLF001) -no-access = "ignore" +no-access = "ignore" # (already covered by ruff SLF001) # reportMissingTypeStubs=false in pyright untyped-import = "ignore" From 15ab7b5da140882896d99c5d826ec6a1278785fe Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Wed, 17 Jun 2026 03:47:48 +0000 Subject: [PATCH 07/25] node --- .devcontainer/devcontainer.json | 2 +- .devcontainer/install-ci-tooling.py | 4 ++-- extensions/context.py | 6 +++--- template/.devcontainer/devcontainer.json.jinja-base | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 04895765f..7c48ab6b5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -68,5 +68,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 2110eb60 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): d504eb59 # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index 7f89987b1..5b3a4a07e 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -5,8 +5,8 @@ import subprocess import sys -UV_VERSION = "0.11.19" -PNPM_VERSION = "11.5.2" +UV_VERSION = "0.11.21" +PNPM_VERSION = "11.7.0" COPIER_VERSION = "9.15.1" COPIER_TEMPLATE_EXTENSIONS_VERSION = "0.3.3" PRE_COMMIT_VERSION = "4.5.1" diff --git a/extensions/context.py b/extensions/context.py index 2d49e7197..ef6e2037c 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -13,15 +13,15 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just self, context: dict[Any, Any] ) -> dict[Any, Any]: # These are duplicated in the install-ci-tooling.py script in this repository - context["uv_version"] = "0.11.19" + context["uv_version"] = "0.11.21" context["pre_commit_version"] = "4.5.1" - context["pnpm_version"] = "11.5.2" + context["pnpm_version"] = "11.7.0" # These also in pyproject.toml and the install-ci-tooling.py script in this repository context["copier_version"] = "==9.15.1" context["copier_template_extensions_version"] = "==0.3.3" ####### context["npm_version"] = "11.13.0" - context["nvm_version"] = "0.40.4" + context["nvm_version"] = "0.40.5" # These are duplicated in the pyproject.toml of this repository context["pyright_version"] = ">=1.1.410" context["pytest_version"] = ">=9.1.0" diff --git a/template/.devcontainer/devcontainer.json.jinja-base b/template/.devcontainer/devcontainer.json.jinja-base index 1d53661cf..4077f0e1d 100644 --- a/template/.devcontainer/devcontainer.json.jinja-base +++ b/template/.devcontainer/devcontainer.json.jinja-base @@ -18,7 +18,7 @@ // view latest version https://raw.githubusercontent.com/aws/aws-cli/v2/CHANGELOG.rst "version": "2.34.34" },{% endraw %}{% endif %}{% raw %}{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_typescript is defined and template_uses_typescript is sameas(true) %}{% raw %} - "ghcr.io/devcontainers/features/node:2.0.0": { + "ghcr.io/devcontainers/features/node:2.1.0": { // https://github.com/devcontainers/features/blob/main/src/node/devcontainer-feature.json "version": "{% endraw %}{{ node_version }}{% raw %}", "pnpmVersion": "{% endraw %}{{ pnpm_version }}{% raw %}", From bba6854e0e1a7703d9460575180af7cd5ed5dd59 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 18 Jun 2026 13:27:02 +0000 Subject: [PATCH 08/25] pytest assertions --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index d16d74e38..337f823b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ This project is a Copier template used to generate other copier templates. It is ### Python Testing +- Keep every behavioral `assert` inside the test function body — never factor assertions into helper functions or fixtures. pytest's assertion rewriting (the rich failure diff) only applies to asserts located in the test module (or conftest/registered plugins), so an `assert` hidden in an ordinary helper reports an opaque failure with no useful diff. - When using `mocker.spy` on a class-level method (including inherited ones), the spy records the unbound call, so assertions need `ANY` as the first argument to match self: `spy.assert_called_once_with(ANY, expected_arg)` - Before writing new mock/spy helpers, check the `tests/unit/` folder for pre-built helpers in files like `fixtures.py` or `*mocks.py` - When a test needs a fixture only for its side effects (not its return value), use `@pytest.mark.usefixtures(fixture_name.__name__)` instead of adding an unused parameter with a noqa comment From 708e258e45ed5776e7d642c3a5c6cf37de4742b3 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Fri, 19 Jun 2026 15:09:51 +0000 Subject: [PATCH 09/25] bump pyrefly --- .devcontainer/devcontainer.json | 2 +- extensions/context.py | 2 +- pyproject.toml | 2 +- uv.lock | 30 ++++++++++++++++-------------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7c48ab6b5..1d2140630 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -68,5 +68,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): d504eb59 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 53e5a34e # spellchecker:disable-line } diff --git a/extensions/context.py b/extensions/context.py index ef6e2037c..e7d0598b4 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -28,7 +28,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["pytest_randomly_version"] = ">=4.1.0" context["pytest_cov_version"] = ">=7.1.0" context["mutmut_version"] = ">=3.6.0" - context["pyrefly_version"] = ">=1.0.0" + context["pyrefly_version"] = ">=1.1.1" ####### context["sphinx_version"] = "9.0.4" context["pulumi_version"] = ">=3.245.0" diff --git a/pyproject.toml b/pyproject.toml index f41dbe549..673d85c08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "pytest-randomly>=4.1.0", "faker>=40.23.0", "pyright[nodejs]>=1.1.410", - "pyrefly>=1.0.0", + "pyrefly>=1.1.1", "mutmut>=3.6.0", "copier==9.15.1", "copier-template-extensions==0.3.3", diff --git a/uv.lock b/uv.lock index f58f2e46d..91d0d19e1 100644 --- a/uv.lock +++ b/uv.lock @@ -84,7 +84,7 @@ requires-dist = [ { name = "copier-template-extensions", specifier = "==0.3.3" }, { name = "faker", specifier = ">=40.23.0" }, { name = "mutmut", specifier = ">=3.6.0" }, - { name = "pyrefly", specifier = ">=1.0.0" }, + { name = "pyrefly", specifier = ">=1.1.1" }, { name = "pyright", extras = ["nodejs"], specifier = ">=1.1.410" }, { name = "pytest", specifier = ">=9.1.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -540,19 +540,21 @@ wheels = [ [[package]] name = "pyrefly" -version = "1.0.0" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/3a/9045b0097ac58979c7c30a4fa0e673db942d4adbc7b6d439bd54ae58c441/pyrefly-1.0.0.tar.gz", hash = "sha256:5c2b810ffcebd84be71de5df1223651edee951653a66935c6f091e957c452455", size = 5677995, upload-time = "2026-05-12T20:12:46.812Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/c6/90788819bac9c61dd7bacba53b79f3c12d47ccbe5e51b3d6d89f2387e1d2/pyrefly-1.0.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e355a0908555348ed4b9585ef25c76ff566673e345c866c325f1633f44d890b6", size = 13122950, upload-time = "2026-05-12T20:12:20.711Z" }, - { url = "https://files.pythonhosted.org/packages/82/91/a3cf2a1e87d336eaa804a1e6fc93266faf6dc2a97eecdbc7eae289628022/pyrefly-1.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7038efc3a40f8294edee339895633cf22db268c0d434cdbcbefc34f78a9ecc3", size = 12599494, upload-time = "2026-05-12T20:12:23.495Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ab/74d1e11e737e99b1c003ecc5d7d2e846c4ea1f328966bfdbbd0ac63fad0a/pyrefly-1.0.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da331ca515ed1c08791da2b5f664cf9c1294c48fd802133262e7d5d51e0f4416", size = 12995507, upload-time = "2026-05-12T20:12:25.951Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ac/2df0899f8464c97e5d995f994c97c5cb5b0f58610432aa90d26d924e1db5/pyrefly-1.0.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c74219d8f3e63cdaa5501a0b21d1c9d37011820f9606728d0ed06f09ae86a878", size = 13947693, upload-time = "2026-05-12T20:12:29.188Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3e/b247c24321e36f04b7d51f9ccf3df93e5009e4b29939524b36ec2e17dc2a/pyrefly-1.0.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c0d05543b1bb6ee6d64149eb5d6b2fb15aa72d3962d6a97abca0afaca8b0c131", size = 13925803, upload-time = "2026-05-12T20:12:31.904Z" }, - { url = "https://files.pythonhosted.org/packages/61/16/cfa2d61a4aa1e1f7bca48bb37acd01c6a09db4864b16a54f9587092765ff/pyrefly-1.0.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1382d5b1fcdb49a4de9f34d112d2bddf290a78ff93ee8149492ad5f1077ddffc", size = 13470398, upload-time = "2026-05-12T20:12:35.302Z" }, - { url = "https://files.pythonhosted.org/packages/cb/2b/6372c7dddb326223e24a46b17efd0d4bd7b4fe22c821e523157577eed2d2/pyrefly-1.0.0-py3-none-win32.whl", hash = "sha256:aa8b5d0e47080e3202a2547b39f7a5a61d2c781c712b3b67884f745ca2c759d2", size = 12222643, upload-time = "2026-05-12T20:12:38.618Z" }, - { url = "https://files.pythonhosted.org/packages/be/ad/1d23be700b6b2ddaeb362360c7145917a8edbbf7240ae428d40541772fce/pyrefly-1.0.0-py3-none-win_amd64.whl", hash = "sha256:c8abcb0f2082e83c890375128f9cff4aa4d3f210b85eea7b3046c1ae764e77f5", size = 13146369, upload-time = "2026-05-12T20:12:41.423Z" }, - { url = "https://files.pythonhosted.org/packages/8c/38/16589134f3012fd097a10dcc85771555f1a5fb76e04b682597180743af30/pyrefly-1.0.0-py3-none-win_arm64.whl", hash = "sha256:d150fa9e40e8392832be81c3bcfc0497c146674ce4d0f8e04e1ec29e775ffb8c", size = 12538326, upload-time = "2026-05-12T20:12:43.996Z" }, +version = "1.1.1" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/20/976165fa4b1517a1a92f393b3f4d4badabfff1165eff09d4cd4908428183/pyrefly-1.1.1.tar.gz", hash = "sha256:6deda959f8603a7dbdf112c48983e2275b2903cf33c8c739ed65d7e71a4fd520", size = 5880491, upload-time = "2026-06-18T23:45:43.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/d6/02ba666018c6a1cb4ddfa2db98ada721adddd374db5c29ba47a0bf2637fa/pyrefly-1.1.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f4b8595f91885bc8b5e3c282ab68d1df21201668a84e6508b1e15f2feec0bb8d", size = 13631867, upload-time = "2026-06-18T23:45:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/7a3457dbbddb513a83cf4fe527d5d5ebda5201a1010ad2a6034030e3e358/pyrefly-1.1.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6b238e1362622d47a6eb5af704fd8b613c94e8c303386efd6350e3da59fecc8", size = 13075304, upload-time = "2026-06-18T23:45:16.865Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/70f4b3f42d58ed686a80df31e04eca54d88036cea4f9b96195c64ad0b2b5/pyrefly-1.1.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b50d4510e4f8aaea79e2c4b343a4d7a060c9451c0b2aa9bfe10d7ca1ef33d68d", size = 13446966, upload-time = "2026-06-18T23:45:19.644Z" }, + { url = "https://files.pythonhosted.org/packages/3c/53/12a19bd6c7af985bcbc13c6910d0f9f6684069ead2282a5c08c2bfbb5d03/pyrefly-1.1.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f330cf039ef3da3b910c84f3a7e431f0cf8d0c1d2dad26491d6cadf3c7cd4759", size = 14449222, upload-time = "2026-06-18T23:45:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/93/f0/e55c48a50076fc0f9ecf4bdedec50456db383e01162f5e2121f8468be071/pyrefly-1.1.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6342d87c52b04f72156da04f554c4d57f3616f2b32d1763969efb22d05a1407", size = 14472947, upload-time = "2026-06-18T23:45:24.858Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e7/30e085b31fed978ecb675bdbb54df566673ab550469e5af2d350f6af0be6/pyrefly-1.1.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c08b814ad03175e9cf47111390537161828b472044c39ab3320252b3ac6b2edd", size = 13975252, upload-time = "2026-06-18T23:45:27.247Z" }, + { url = "https://files.pythonhosted.org/packages/47/58/49c3e67641133d3fe5d8d9a660dc0826c6c37ca197d86cad05fa7dd8bfd6/pyrefly-1.1.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d50cad97f19fc893b04deff7239626cffff5dd27ffb29b7d303a1b770247b208", size = 13471780, upload-time = "2026-06-18T23:45:29.775Z" }, + { url = "https://files.pythonhosted.org/packages/71/1e/65a7ba8355e2c39d8331832905fb74dcc85fc122a3f1dfd6dbf2a88907ad/pyrefly-1.1.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2150b450ee6a6bcbe69b2d45d9a4ebc934a609e1abcf65e490433f38eb873d84", size = 13989306, upload-time = "2026-06-18T23:45:32.576Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/b7ee1ab2392c36945738246fba7524439810befa3cfcc03cb6157567fc10/pyrefly-1.1.1-py3-none-win32.whl", hash = "sha256:5ffd8a8ed62fe4e6bf0afe1837d1bad149bb3b9f80e928ef248c96b836db3742", size = 12608469, upload-time = "2026-06-18T23:45:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/a0f5b52934bf80e9c7eff08222e7caf318287b9aef76acb8d9ac5740581b/pyrefly-1.1.1-py3-none-win_amd64.whl", hash = "sha256:4e0430f3ef69c8ac73505fd6584db70ed504665a9f0816fef7f723de510f26cb", size = 13502172, upload-time = "2026-06-18T23:45:38.375Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/4c6bcb3d456835f51445d3662a428f56c3ea5643ec798c577030ae34298c/pyrefly-1.1.1-py3-none-win_arm64.whl", hash = "sha256:83baf0db71e172665db1fca0ced50b8f7773f5192ca57e8ac6773a772b6d2fc5", size = 12895979, upload-time = "2026-06-18T23:45:41.026Z" }, ] [[package]] From 2482a8c39965fbff329b75a3350d12e438fb6a58 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Fri, 19 Jun 2026 16:16:02 +0000 Subject: [PATCH 10/25] derive commit link --- .claude/skills/address-pr-comments/SKILL.md | 5 +- .../skills/address-pr-comments/commit-link.py | 97 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/address-pr-comments/commit-link.py diff --git a/.claude/skills/address-pr-comments/SKILL.md b/.claude/skills/address-pr-comments/SKILL.md index 314b9b3be..abda92d0e 100644 --- a/.claude/skills/address-pr-comments/SKILL.md +++ b/.claude/skills/address-pr-comments/SKILL.md @@ -215,7 +215,10 @@ Within a single Phase 2 invocation, the order below is strict. The invariants ap On `Edit first`: after user confirms edits done, run the footer check, Read the file, share opinion, then ask again: `Approve or edit again?` with options `Approve` / `Edit again`. Loop until approved. - **Commit** — one commit per comment, no batching, no exceptions. This applies to all changes including docs and markdown. - - Capture commit hash (`git rev-parse HEAD`) and repo URL (`git remote get-url origin`). Replace the `[COMMIT LINK]` placeholder in `` with the real link. + - Fill in the `[COMMIT LINK]` placeholder in ``: + ```bash + .claude/skills/address-pr-comments/commit-link.py + ``` - Run footer check: `.claude/skills/address-pr-comments/check-footer.py ` - Update bd issue description to store commit hash and reply: `bd update --description="\n\ncommit: \nreply: " --json` - Close the bd issue (`bd close --reason "Addressed in PR review" --json`) diff --git a/.claude/skills/address-pr-comments/commit-link.py b/.claude/skills/address-pr-comments/commit-link.py new file mode 100644 index 000000000..616f2c6f2 --- /dev/null +++ b/.claude/skills/address-pr-comments/commit-link.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Replace the [COMMIT LINK] placeholder in a reply file with the PR-scoped commit link. + +Usage: commit-link.py [--pr ] [--commit ] + +Everything is self-derived when the optional flags are omitted: + --pr auto-detected from the current branch via `gh pr view` + --commit defaults to HEAD + +The link points at the commit *within the PR* (/pull//changes/) +rather than the repo-wide commit view (/commit/), so replies posted +against it are associated with the PR. Owner and repo are derived from the +git remote automatically. + +Prints "replaced" on success. Exits non-zero if the placeholder is absent. +""" + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from utils import owner_repo_from_remote + +PLACEHOLDER = "[COMMIT LINK]" + + +def resolve_commit(ref: str) -> str: + try: + result = subprocess.run( # noqa: S603 — ref is a local default or operator-supplied commit ref + ["git", "rev-parse", ref], # noqa: S607 — git is expected on PATH + capture_output=True, + text=True, + check=True, + timeout=15, + ) + except subprocess.TimeoutExpired: + _ = sys.stderr.write(f"Timed out resolving commit ref '{ref}'.\n") + sys.exit(1) + except subprocess.CalledProcessError as e: + _ = sys.stderr.write(f"Cannot resolve commit ref '{ref}': {e.stderr}\n") + sys.exit(1) + return result.stdout.strip() + + +def detect_pr() -> int: + try: + result = subprocess.run( + ["gh", "pr", "view", "--json", "number"], # noqa: S607 — gh is expected on PATH + capture_output=True, + text=True, + check=True, + timeout=30, + ) + except subprocess.TimeoutExpired: + _ = sys.stderr.write("Timed out detecting the PR for the current branch.\n") + sys.exit(1) + except subprocess.CalledProcessError as e: + _ = sys.stderr.write(f"Cannot detect a PR for the current branch: {e.stderr}\nPass --pr explicitly.\n") + sys.exit(1) + return int(json.loads(result.stdout)["number"]) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + _ = parser.add_argument("reply_file", type=Path, help="Reply file containing the [COMMIT LINK] placeholder") + _ = parser.add_argument("--pr", type=int, default=None, help="PR number (default: auto-detect from current branch)") + _ = parser.add_argument("--commit", default="HEAD", help="Commit ref to link (default: HEAD)") + args = parser.parse_args() + + try: + content = args.reply_file.read_text(encoding="utf-8") + except OSError as e: + _ = sys.stderr.write(f"File error for {args.reply_file}: {e}\n") + sys.exit(1) + + if PLACEHOLDER not in content: + _ = sys.stderr.write(f"Placeholder {PLACEHOLDER} not found in {args.reply_file}.\n") + sys.exit(1) + + owner, repo = owner_repo_from_remote() + pr = args.pr if args.pr is not None else detect_pr() + commit = resolve_commit(args.commit) + link = f"https://github.com/{owner}/{repo}/pull/{pr}/changes/{commit}" + + try: + _ = args.reply_file.write_text(content.replace(PLACEHOLDER, link), encoding="utf-8") + except OSError as e: + _ = sys.stderr.write(f"File error for {args.reply_file}: {e}\n") + sys.exit(1) + _ = sys.stdout.write("replaced\n") + + +if __name__ == "__main__": + main() From 7de5e1366dd3f1c99c9b3e4dc59f846b2a6356db Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Fri, 19 Jun 2026 17:22:15 +0000 Subject: [PATCH 11/25] verify env --- .claude/skills/address-pr-comments/SKILL.md | 54 ++++------- .../skills/address-pr-comments/verify-env.py | 96 +++++++++++++++++++ template/copier.yml.jinja-base | 2 +- 3 files changed, 117 insertions(+), 35 deletions(-) create mode 100644 .claude/skills/address-pr-comments/verify-env.py diff --git a/.claude/skills/address-pr-comments/SKILL.md b/.claude/skills/address-pr-comments/SKILL.md index abda92d0e..6f4fb256d 100644 --- a/.claude/skills/address-pr-comments/SKILL.md +++ b/.claude/skills/address-pr-comments/SKILL.md @@ -15,7 +15,7 @@ Read PR review comments and address them by making code changes, answering quest ## Prerequisites - Must be on the PR's branch (not main/master) -- PR must exist and be open +- PR must exist and not be merged (open or closed is fine) ## Workflow @@ -23,28 +23,28 @@ Read PR review comments and address them by making code changes, answering quest **Always show file paths to the user as absolute paths.** This applies to every file path that appears in a user-facing message — reply files this skill drafts, source files a comment is attached to (e.g. when announcing "Comment 2 of 4 — line 33"), or any other file reference. VS Code only Ctrl+click-opens a path when it is fully qualified (e.g. `/workspaces/my-app/src/foo.py`), not when it is relative (`src/foo.py`). The `path` field returned by `fetch-pr-comments.py` is relative to the repo root — prepend the repo root before showing it. -Capture the repo root once at the start of the skill (it does not change mid-session) and reuse it: - -```bash -git rev-parse --show-toplevel -``` +Capture the repo root once at the start of the skill (it does not change mid-session) and reuse it. It is the `repo_root` field of the [Step 1](#step-1-verify-environment-and-identify-the-pr) `verify-env.py` verdict. **Reply file path.** Throughout this document, `` refers to the absolute path: `/tmp/pr-reply-.txt`. Use this form everywhere — shell commands, Write/Read tool calls, and user-facing messages. -### Step 1: Verify Environment +### Step 1: Verify Environment and Identify the PR -Check we have a GitHub remote and are on a feature branch: +Run the gate script once. Pass `--pr ` if the user supplied one; otherwise it auto-detects the PR from the current branch: ```bash -git remote -v -git status -git rev-parse --abbrev-ref HEAD +.claude/skills/address-pr-comments/verify-env.py [--pr ] ``` +It emits a JSON verdict: `repo_root`, `has_remote`, `branch`, `on_protected_branch`, `dirty`, and `pr` (`{number, state, title}` or `null`). This is the source for both the repo root (see [Conventions](#conventions)) and the PR — no separate `git rev-parse` or `gh pr view` calls are needed. + **STOP if:** -- No remote exists → "This skill requires a GitHub remote. Please add one with `git remote add origin ` first." -- On main/master → "Please switch to the PR's branch first. You can find it with `gh pr view --json headRefName`" -- Uncommitted changes → "Please commit or stash your changes first." *(skipped when `--resume` is passed — see [Resume Mode](#resume-mode))* +- `has_remote` is `false` → "This skill requires a GitHub remote. Please add one with `git remote add origin ` first." +- `on_protected_branch` is `true` → "Please switch to the PR's branch first. You can find it with `gh pr view --json headRefName`" +- `dirty` is `true` → "Please commit or stash your changes first." *(skipped when `--resume` is passed — see [Resume Mode](#resume-mode))* + +**PR resolution:** +- `pr` is `null` → ask the user for a PR number, then re-run with `--pr `. +- `pr.state` is `MERGED` → inform the user the PR is already merged and stop. `OPEN` and `CLOSED` both proceed normally (a PR is sometimes closed while a large batch of comments is worked through). ### Resume Mode @@ -90,19 +90,7 @@ Options: - If remote has diverged (force-push, rebase): stop, surface diff, do nothing. - Reply files with placeholder still present block the single-push step — they must be re-entered into Phase 2 (commit produced) before the batch push fires. -### Step 2: Identify the PR - -User will provide a PR number (e.g., `#12` or `12`), or auto-detect from current branch: - -```bash -gh pr view --json number,state,title -``` - -If no PR exists for the current branch, ask the user for a PR number. - -Validate the PR is open. If closed/merged, inform the user and stop. - -### Step 3: Fetch PR Comments +### Step 2: Fetch PR Comments Run the fetch script to get all actionable comments, pre-grouped into threads: @@ -124,7 +112,7 @@ For `issues/comments` type: no threading — replying posts a new top-level PR c Empty array → inform user, stop. -### Step 4: Phase 1 — Collect Decisions +### Step 3: Phase 1 — Collect Decisions Collect all decisions before executing — exception: immediate replies (see below). @@ -188,9 +176,9 @@ For each comment: 5. **Loop** until all comments are processed or user wants to stop -### Step 5: Phase 2 — Execute (Batch Executor) +### Step 4: Phase 2 — Execute (Batch Executor) -Phase 2 is a **batch executor** that drains the current queue. Run it once at the end of Phase 1, or multiple times via the "Flush queue now" option — each invocation processes whatever is queued at that moment, then control returns to Phase 1 (if comments remain) or to Step 6. +Phase 2 is a **batch executor** that drains the current queue. Run it once at the end of Phase 1, or multiple times via the "Flush queue now" option — each invocation processes whatever is queued at that moment, then control returns to Phase 1 (if comments remain) or to Step 5. Within a single Phase 2 invocation, the order below is strict. The invariants apply per-batch, not per-session. @@ -240,7 +228,7 @@ Within a single Phase 2 invocation, the order below is strict. The invariants ap .claude/skills/address-pr-comments/post-reply.py --comment-id --comment-type issues/comments --body-file --pr ``` -### Step 6: Report Completion +### Step 5: Report Completion Summarise what was done: - Number of comments addressed @@ -281,10 +269,8 @@ Discuss with user, then post a reply explaining the reasoning if they want to pu ## Checklist -- [ ] Verify on feature branch (not main/master) +- [ ] Run `verify-env.py`: feature branch (not main/master), remote exists, tree clean, PR identified and not merged - [ ] If `--resume`: build inventory, classify reply drafts (placeholder vs finalized vs orphan), prompt user for resume strategy -- [ ] Identify PR (from argument or auto-detect) -- [ ] Verify PR is open - [ ] Fetch and display comments - [ ] Phase 1: collect decisions for all comments (reply-only comments posted in Phase 1) - [ ] Phase 2: implement code changes, one commit per comment diff --git a/.claude/skills/address-pr-comments/verify-env.py b/.claude/skills/address-pr-comments/verify-env.py new file mode 100644 index 000000000..139b7eaad --- /dev/null +++ b/.claude/skills/address-pr-comments/verify-env.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Gather environment facts for the address-pr-comments skill as one JSON verdict. + +Usage: verify-env.py [--pr ] + +Emits a JSON object to stdout: + repo_root absolute path to the repo root + has_remote whether any git remote is configured + branch current branch name + on_protected_branch whether the branch is main or master + dirty whether the working tree has uncommitted changes + pr {number, state, title} for the PR, or null if none found + +This reports facts only — it does not enforce the skill's STOP / resume policy. +The caller decides what to do (e.g. dirty is fatal normally but expected under +--resume). With --pr, that PR is looked up; otherwise the PR is auto-detected +from the current branch. +""" + +import argparse +import json +import subprocess +import sys + + +def run(cmd: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 — cmd is a fixed argv of literals plus a validated PR number + cmd, + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + + +def repo_root() -> str: + result = run(["git", "rev-parse", "--show-toplevel"], timeout=15) + if result.returncode != 0: + _ = sys.stderr.write(f"Not inside a git repository: {result.stderr.strip()}\n") + sys.exit(1) + return result.stdout.strip() + + +def current_branch() -> str: + result = run(["git", "rev-parse", "--abbrev-ref", "HEAD"], timeout=15) + if result.returncode != 0: + _ = sys.stderr.write(f"Cannot determine current branch: {result.stderr.strip()}\n") + sys.exit(1) + return result.stdout.strip() + + +def has_remote() -> bool: + result = run(["git", "remote"], timeout=15) + if result.returncode != 0: + _ = sys.stderr.write(f"Cannot read git remotes: {result.stderr.strip()}\n") + sys.exit(1) + return bool(result.stdout.strip()) + + +def is_dirty() -> bool: + result = run(["git", "status", "--porcelain"], timeout=15) + if result.returncode != 0: + _ = sys.stderr.write(f"Cannot read git status: {result.stderr.strip()}\n") + sys.exit(1) + return bool(result.stdout.strip()) + + +def find_pr(pr_number: int | None) -> dict[str, object] | None: + target = [] if pr_number is None else [str(pr_number)] + result = run(["gh", "pr", "view", *target, "--json", "number,state,title"], timeout=30) + if result.returncode != 0: + return None + return json.loads(result.stdout) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + _ = parser.add_argument("--pr", type=int, default=None, help="PR number (default: auto-detect from current branch)") + args = parser.parse_args() + + branch = current_branch() + on_protected_branch = branch in {"main", "master"} + + verdict = { + "repo_root": repo_root(), + "has_remote": has_remote(), + "branch": branch, + "on_protected_branch": on_protected_branch, + "dirty": is_dirty(), + "pr": find_pr(args.pr), + } + _ = sys.stdout.write(json.dumps(verdict, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/template/copier.yml.jinja-base b/template/copier.yml.jinja-base index 4f3fe1849..26edbb3c8 100644 --- a/template/copier.yml.jinja-base +++ b/template/copier.yml.jinja-base @@ -177,7 +177,7 @@ _tasks: fi fi - command: | - for script in .claude/skills/address-pr-comments/check-footer.py .claude/skills/address-pr-comments/fetch-pr-comments.py .claude/skills/address-pr-comments/post-reply.py; do + for script in .claude/skills/address-pr-comments/check-footer.py .claude/skills/address-pr-comments/commit-link.py .claude/skills/address-pr-comments/fetch-pr-comments.py .claude/skills/address-pr-comments/post-reply.py .claude/skills/address-pr-comments/verify-env.py; do if [ -f "$script" ]; then chmod +x "$script" if git ls-files --error-unmatch "$script" > /dev/null 2>&1; then From 67bae4acf80a9b2cb3f9cf8623637ac6d819c2c3 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 15:48:42 +0000 Subject: [PATCH 12/25] pr open --- .claude/skills/address-pr-comments/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/skills/address-pr-comments/SKILL.md b/.claude/skills/address-pr-comments/SKILL.md index 6f4fb256d..433adaad1 100644 --- a/.claude/skills/address-pr-comments/SKILL.md +++ b/.claude/skills/address-pr-comments/SKILL.md @@ -15,7 +15,7 @@ Read PR review comments and address them by making code changes, answering quest ## Prerequisites - Must be on the PR's branch (not main/master) -- PR must exist and not be merged (open or closed is fine) +- PR must exist and be open ## Workflow @@ -44,7 +44,7 @@ It emits a JSON verdict: `repo_root`, `has_remote`, `branch`, `on_protected_bran **PR resolution:** - `pr` is `null` → ask the user for a PR number, then re-run with `--pr `. -- `pr.state` is `MERGED` → inform the user the PR is already merged and stop. `OPEN` and `CLOSED` both proceed normally (a PR is sometimes closed while a large batch of comments is worked through). +- `pr.state` is not `OPEN` → inform the user the PR must be open and stop. ### Resume Mode @@ -269,7 +269,7 @@ Discuss with user, then post a reply explaining the reasoning if they want to pu ## Checklist -- [ ] Run `verify-env.py`: feature branch (not main/master), remote exists, tree clean, PR identified and not merged +- [ ] Run `verify-env.py`: feature branch (not main/master), remote exists, tree clean, PR identified and open - [ ] If `--resume`: build inventory, classify reply drafts (placeholder vs finalized vs orphan), prompt user for resume strategy - [ ] Fetch and display comments - [ ] Phase 1: collect decisions for all comments (reply-only comments posted in Phase 1) From f152ff48fc685758e3bee615f97d479eac62c498 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 15:54:33 +0000 Subject: [PATCH 13/25] pytest --- .devcontainer/devcontainer.json | 2 +- .devcontainer/install-ci-tooling.py | 4 ++-- extensions/context.py | 8 ++++---- pyproject.toml | 4 ++-- uv.lock | 16 ++++++++-------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1d2140630..8aa00c031 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -68,5 +68,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 53e5a34e # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 1c66a8d1 # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index 5b3a4a07e..814589b75 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -5,8 +5,8 @@ import subprocess import sys -UV_VERSION = "0.11.21" -PNPM_VERSION = "11.7.0" +UV_VERSION = "0.11.25" +PNPM_VERSION = "11.9.0" COPIER_VERSION = "9.15.1" COPIER_TEMPLATE_EXTENSIONS_VERSION = "0.3.3" PRE_COMMIT_VERSION = "4.5.1" diff --git a/extensions/context.py b/extensions/context.py index e7d0598b4..1bd18ed0b 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -13,9 +13,9 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just self, context: dict[Any, Any] ) -> dict[Any, Any]: # These are duplicated in the install-ci-tooling.py script in this repository - context["uv_version"] = "0.11.21" + context["uv_version"] = "0.11.25" context["pre_commit_version"] = "4.5.1" - context["pnpm_version"] = "11.7.0" + context["pnpm_version"] = "11.9.0" # These also in pyproject.toml and the install-ci-tooling.py script in this repository context["copier_version"] = "==9.15.1" context["copier_template_extensions_version"] = "==0.3.3" @@ -23,8 +23,8 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["npm_version"] = "11.13.0" context["nvm_version"] = "0.40.5" # These are duplicated in the pyproject.toml of this repository - context["pyright_version"] = ">=1.1.410" - context["pytest_version"] = ">=9.1.0" + context["pyright_version"] = ">=1.1.411" + context["pytest_version"] = ">=9.1.1" context["pytest_randomly_version"] = ">=4.1.0" context["pytest_cov_version"] = ">=7.1.0" context["mutmut_version"] = ">=3.6.0" diff --git a/pyproject.toml b/pyproject.toml index 673d85c08..3224cfafe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,11 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12.7" dependencies = [ - "pytest>=9.1.0", + "pytest>=9.1.1", "pytest-cov>=7.1.0", "pytest-randomly>=4.1.0", "faker>=40.23.0", - "pyright[nodejs]>=1.1.410", + "pyright[nodejs]>=1.1.411", "pyrefly>=1.1.1", "mutmut>=3.6.0", "copier==9.15.1", diff --git a/uv.lock b/uv.lock index 91d0d19e1..68ea2489d 100644 --- a/uv.lock +++ b/uv.lock @@ -85,8 +85,8 @@ requires-dist = [ { name = "faker", specifier = ">=40.23.0" }, { name = "mutmut", specifier = ">=3.6.0" }, { name = "pyrefly", specifier = ">=1.1.1" }, - { name = "pyright", extras = ["nodejs"], specifier = ">=1.1.410" }, - { name = "pytest", specifier = ">=9.1.0" }, + { name = "pyright", extras = ["nodejs"], specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-randomly", specifier = ">=4.1.0" }, { name = "pyyaml", specifier = ">=6.0.3" }, @@ -559,15 +559,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.410" +version = "1.1.411" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/53/e4d8ea1391bd4355231be6f91bf239479aa0014260ed3fb5526eeb12a1f2/pyright-1.1.410.tar.gz", hash = "sha256:07a073b8ba6749826773c1269773efa11b93440d9a6aa60419d9a3172d6dc488", size = 4062013, upload-time = "2026-06-01T17:35:48.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/33/288b5868fa00846dacf249633719d747893e54aebd196b9968ac1878a5d3/pyright-1.1.410-py3-none-any.whl", hash = "sha256:5e961bed37cacf96b3f7cd7b1da39b350a9239aa2e69138d0e88f728cfaf296c", size = 6082448, upload-time = "2026-06-01T17:35:46.387Z" }, + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, ] [package.optional-dependencies] @@ -577,7 +577,7 @@ nodejs = [ [[package]] name = "pytest" -version = "9.1.0" +version = "9.1.1" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -586,9 +586,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] From 048f5f223c1ce448b5a994603520c7a932848c22 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:02:59 +0000 Subject: [PATCH 14/25] dryer --- .claude/settings/permissions/bash.jsonc | 14 ++--------- .../skills/address-pr-comments/commit-link.py | 23 ++++++------------ .../skills/address-pr-comments/post-reply.py | 12 ++++------ .claude/skills/address-pr-comments/utils.py | 24 +++++++++++++++---- .../skills/address-pr-comments/verify-env.py | 0 .devcontainer/devcontainer.json | 8 +++---- extensions/context.py | 12 +++++----- .../devcontainer.json.jinja-base | 8 +++---- 8 files changed, 46 insertions(+), 55 deletions(-) mode change 100644 => 100755 .claude/skills/address-pr-comments/commit-link.py mode change 100644 => 100755 .claude/skills/address-pr-comments/verify-env.py diff --git a/.claude/settings/permissions/bash.jsonc b/.claude/settings/permissions/bash.jsonc index faac2d95f..f276cdfe1 100644 --- a/.claude/settings/permissions/bash.jsonc +++ b/.claude/settings/permissions/bash.jsonc @@ -41,18 +41,8 @@ "Bash(tar *)", "Bash(touch *)", "Bash(tree *)", - // Git & Version Control - "Bash(git add *)", - "Bash(git branch *)", - "Bash(git checkout *)", - "Bash(git commit *)", - "Bash(git diff *)", - "Bash(git log *)", - "Bash(git mv *)", - "Bash(git push)", - "Bash(git remote get-url origin)", - "Bash(git rev-parse *)", - "Bash(git status *)", + // Git & Version Control - anything not explicitly denied + "Bash(git *)", // Github "Bash(gh issue list *)", "Bash(gh issue view *)", diff --git a/.claude/skills/address-pr-comments/commit-link.py b/.claude/skills/address-pr-comments/commit-link.py old mode 100644 new mode 100755 index 616f2c6f2..a26c38c30 --- a/.claude/skills/address-pr-comments/commit-link.py +++ b/.claude/skills/address-pr-comments/commit-link.py @@ -23,22 +23,18 @@ sys.path.insert(0, str(Path(__file__).parent)) from utils import owner_repo_from_remote +from utils import run_cmd PLACEHOLDER = "[COMMIT LINK]" def resolve_commit(ref: str) -> str: try: - result = subprocess.run( # noqa: S603 — ref is a local default or operator-supplied commit ref - ["git", "rev-parse", ref], # noqa: S607 — git is expected on PATH - capture_output=True, - text=True, - check=True, + result = run_cmd( + ["git", "rev-parse", ref], timeout=15, + timeout_msg=f"Timed out resolving commit ref '{ref}'.", ) - except subprocess.TimeoutExpired: - _ = sys.stderr.write(f"Timed out resolving commit ref '{ref}'.\n") - sys.exit(1) except subprocess.CalledProcessError as e: _ = sys.stderr.write(f"Cannot resolve commit ref '{ref}': {e.stderr}\n") sys.exit(1) @@ -47,16 +43,11 @@ def resolve_commit(ref: str) -> str: def detect_pr() -> int: try: - result = subprocess.run( - ["gh", "pr", "view", "--json", "number"], # noqa: S607 — gh is expected on PATH - capture_output=True, - text=True, - check=True, + result = run_cmd( + ["gh", "pr", "view", "--json", "number"], timeout=30, + timeout_msg="Timed out detecting the PR for the current branch.", ) - except subprocess.TimeoutExpired: - _ = sys.stderr.write("Timed out detecting the PR for the current branch.\n") - sys.exit(1) except subprocess.CalledProcessError as e: _ = sys.stderr.write(f"Cannot detect a PR for the current branch: {e.stderr}\nPass --pr explicitly.\n") sys.exit(1) diff --git a/.claude/skills/address-pr-comments/post-reply.py b/.claude/skills/address-pr-comments/post-reply.py index efb37e716..4500e3c2b 100755 --- a/.claude/skills/address-pr-comments/post-reply.py +++ b/.claude/skills/address-pr-comments/post-reply.py @@ -22,6 +22,7 @@ sys.path.insert(0, str(Path(__file__).parent)) from utils import owner_repo_from_remote +from utils import run_cmd def main() -> None: @@ -51,8 +52,8 @@ def main() -> None: endpoint = f"repos/{owner}/{repo}/issues/{args.pr}/comments" try: - result = subprocess.run( # noqa: S603 — endpoint and body are constructed from validated inputs - [ # noqa: S607 — gh is expected on PATH + result = run_cmd( + [ "gh", "api", endpoint, @@ -61,14 +62,9 @@ def main() -> None: "--raw-field", f"body={body}", ], - capture_output=True, - text=True, - check=True, timeout=30, + timeout_msg="gh api timed out while posting reply.", ) - except subprocess.TimeoutExpired: - _ = sys.stderr.write("gh api timed out while posting reply.\n") - sys.exit(1) except subprocess.CalledProcessError as e: _ = sys.stderr.write(f"gh api error: {e.stderr}\n") sys.exit(1) diff --git a/.claude/skills/address-pr-comments/utils.py b/.claude/skills/address-pr-comments/utils.py index b80a2f769..d68f10fc8 100644 --- a/.claude/skills/address-pr-comments/utils.py +++ b/.claude/skills/address-pr-comments/utils.py @@ -3,18 +3,32 @@ import sys -def owner_repo_from_remote() -> tuple[str, str]: +def run_cmd( + cmd: list[str], + *, + timeout: int, + timeout_msg: str, +) -> subprocess.CompletedProcess[str]: try: - result = subprocess.run( - ["git", "remote", "get-url", "origin"], # noqa: S607 — git is expected on PATH + return subprocess.run( # noqa: S603 — callers are responsible for only passing safe commands + cmd, capture_output=True, text=True, check=True, - timeout=15, + timeout=timeout, ) except subprocess.TimeoutExpired: - _ = sys.stderr.write("Timed out reading git remote 'origin'.\n") + _ = sys.stderr.write(f"{timeout_msg}\n") sys.exit(1) + + +def owner_repo_from_remote() -> tuple[str, str]: + try: + result = run_cmd( + ["git", "remote", "get-url", "origin"], + timeout=15, + timeout_msg="Timed out reading git remote 'origin'.", + ) except subprocess.CalledProcessError: _ = sys.stderr.write("Cannot read git remote 'origin'. Ensure it exists and points to GitHub.\n") sys.exit(1) diff --git a/.claude/skills/address-pr-comments/verify-env.py b/.claude/skills/address-pr-comments/verify-env.py old mode 100644 new mode 100755 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8aa00c031..564543a80 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -28,15 +28,15 @@ "-AmazonWebServices.aws-toolkit-vscode", // the AWS CLI feature installs this automatically, but it's causing problems in VS Code // basic tooling // "eamodio.gitlens@15.5.1", - "coderabbit.coderabbit-vscode@0.20.0", + "coderabbit.coderabbit-vscode@0.20.4", "ms-vscode.live-server@0.5.2025051301", "MS-vsliveshare.vsliveshare@1.0.5905", - "anthropic.claude-code@2.1.169", + "anthropic.claude-code@2.1.195", // Python "ms-python.python@2026.5.2026052901", - "meta.pyrefly@1.0.0", + "meta.pyrefly@1.1.1", "ms-vscode-remote.remote-containers@0.414.0", - "charliermarsh.ruff@2026.44.0", + "charliermarsh.ruff@2026.56.0", // Misc file formats "bierner.markdown-mermaid@1.29.0", "samuelcolvin.jinjahtml@0.20.0", diff --git a/extensions/context.py b/extensions/context.py index 1bd18ed0b..3f23b441c 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -53,9 +53,9 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["syrupy_version"] = ">=5.3.1" context["structlog_version"] = ">=26.1.0" context["httpx_version"] = ">=0.28.1" - context["httpx2_version"] = ">=2.3.0" - context["python_kiota_bundle_version"] = ">=1.10.2" - context["vcrpy_version"] = ">=8.1.1" + context["httpx2_version"] = ">=2.5.0" + context["python_kiota_bundle_version"] = ">=1.11.6" + context["vcrpy_version"] = ">=8.2.1" context["pytest_recording_version"] = ">=0.13.4" context["pytest_asyncio_version"] = ">=1.4.0" context["pytest_timeout_version"] = ">=2.4.0" @@ -66,16 +66,16 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["nuxt_version"] = "^4.4.6" context["nuxt_icon_version"] = "^2.2.1" context["typescript_version"] = "^6.0.2" - context["playwright_version"] = "^1.60.0" + context["playwright_version"] = "^1.61.1" context["vue_version"] = "^3.5.30" context["vue_tsc_version"] = "^3.3.2" context["vue_devtools_api_version"] = "^8.1.0" context["vue_router_version"] = "^5.1.0" context["dotenv_cli_version"] = "^11.0.0" - context["faker_version"] = "^10.4.0" + context["faker_version"] = "^10.5.0" context["vitest_version"] = "^4.1.7" context["vitest_eslint_plugin_version"] = "^1.6.17" - context["eslint_version"] = "^10.4.0" + context["eslint_version"] = "^10.5.0" context["nuxt_eslint_version"] = "^1.16.0" context["zod_version"] = "^4.3.6" context["zod_from_json_schema_version"] = "^0.5.1" diff --git a/template/.devcontainer/devcontainer.json.jinja-base b/template/.devcontainer/devcontainer.json.jinja-base index 4077f0e1d..37e77b26a 100644 --- a/template/.devcontainer/devcontainer.json.jinja-base +++ b/template/.devcontainer/devcontainer.json.jinja-base @@ -34,16 +34,16 @@ "-AmazonWebServices.aws-toolkit-vscode", // the AWS CLI feature installs this automatically, but it's causing problems in VS Code{% endraw %}{% endif %}{% raw %} // basic tooling // "eamodio.gitlens@15.5.1", - "coderabbit.coderabbit-vscode@0.20.0", + "coderabbit.coderabbit-vscode@0.20.4", "ms-vscode.live-server@0.5.2025051301", "MS-vsliveshare.vsliveshare@1.0.5905",{% endraw %}{% if install_claude_cli %}{% raw %} - "anthropic.claude-code@2.1.169",{% endraw %}{% endif %}{% raw %}{% endraw %}{% if (is_child_of_copier_base_template is defined and is_child_of_copier_base_template is sameas(true)) or (is_child_of_copier_base_template is not defined and template_uses_python is defined and template_uses_python is sameas(true)) %}{% raw %} + "anthropic.claude-code@2.1.195",{% endraw %}{% endif %}{% raw %}{% endraw %}{% if (is_child_of_copier_base_template is defined and is_child_of_copier_base_template is sameas(true)) or (is_child_of_copier_base_template is not defined and template_uses_python is defined and template_uses_python is sameas(true)) %}{% raw %} // Python "ms-python.python@2026.5.2026052901", - "meta.pyrefly@1.0.0", + "meta.pyrefly@1.1.1", "ms-vscode-remote.remote-containers@0.414.0", - "charliermarsh.ruff@2026.44.0",{% endraw %}{% endif %}{% raw %} + "charliermarsh.ruff@2026.56.0",{% endraw %}{% endif %}{% raw %} {% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_vuejs is defined and template_uses_vuejs is sameas(true) %}{% raw %} // VueJS "vue.volar@3.2.5", From 956606a4f3bde511003cfb18003c8a942078b433 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:09:29 +0000 Subject: [PATCH 15/25] check gh auth before PR lookup to surface clear login error Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/address-pr-comments/verify-env.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.claude/skills/address-pr-comments/verify-env.py b/.claude/skills/address-pr-comments/verify-env.py index 139b7eaad..b83da5c7b 100755 --- a/.claude/skills/address-pr-comments/verify-env.py +++ b/.claude/skills/address-pr-comments/verify-env.py @@ -65,7 +65,15 @@ def is_dirty() -> bool: return bool(result.stdout.strip()) +def check_gh_auth() -> None: + result = run(["gh", "auth", "status"], timeout=15) + if result.returncode != 0: + _ = sys.stderr.write("GitHub CLI is not authenticated. Run: gh auth login\n") + sys.exit(1) + + def find_pr(pr_number: int | None) -> dict[str, object] | None: + check_gh_auth() target = [] if pr_number is None else [str(pr_number)] result = run(["gh", "pr", "view", *target, "--json", "number,state,title"], timeout=30) if result.returncode != 0: From dac9e51ed77bde46d20941d54bd2c0d72594b76c Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:14:30 +0000 Subject: [PATCH 16/25] Pass resolved PR number to commit-link.py in SKILL.md Prevents re-detecting the PR from the branch when the PR number is already known from Step 1, avoiding wrong commit links when a branch maps to multiple PRs or --pr was used. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/address-pr-comments/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/address-pr-comments/SKILL.md b/.claude/skills/address-pr-comments/SKILL.md index 433adaad1..a8e53a745 100644 --- a/.claude/skills/address-pr-comments/SKILL.md +++ b/.claude/skills/address-pr-comments/SKILL.md @@ -205,7 +205,7 @@ Within a single Phase 2 invocation, the order below is strict. The invariants ap - **Commit** — one commit per comment, no batching, no exceptions. This applies to all changes including docs and markdown. - Fill in the `[COMMIT LINK]` placeholder in ``: ```bash - .claude/skills/address-pr-comments/commit-link.py + .claude/skills/address-pr-comments/commit-link.py --pr ``` - Run footer check: `.claude/skills/address-pr-comments/check-footer.py ` - Update bd issue description to store commit hash and reply: `bd update --description="\n\ncommit: \nreply: " --json` From c2a19535998080226de090947faa4180badca32c Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:17:23 +0000 Subject: [PATCH 17/25] Validate origin+GitHub URL in verify-env.py has_remote() Prevents the skill from proceeding through Phase 1 when origin either doesn't exist or isn't a GitHub remote, surfacing the failure at Step 1 instead of crashing at commit-link.py after work is already committed. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/address-pr-comments/verify-env.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.claude/skills/address-pr-comments/verify-env.py b/.claude/skills/address-pr-comments/verify-env.py index b83da5c7b..9fca32b68 100755 --- a/.claude/skills/address-pr-comments/verify-env.py +++ b/.claude/skills/address-pr-comments/verify-env.py @@ -19,6 +19,7 @@ import argparse import json +import re import subprocess import sys @@ -49,12 +50,20 @@ def current_branch() -> str: return result.stdout.strip() +_GITHUB_URL_RE = re.compile(r"(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)[^/]+/[^/]+?(?:\.git)?/?") + + def has_remote() -> bool: result = run(["git", "remote"], timeout=15) if result.returncode != 0: _ = sys.stderr.write(f"Cannot read git remotes: {result.stderr.strip()}\n") sys.exit(1) - return bool(result.stdout.strip()) + if "origin" not in result.stdout.split(): + return False + url_result = run(["git", "remote", "get-url", "origin"], timeout=15) + if url_result.returncode != 0: + return False + return bool(_GITHUB_URL_RE.fullmatch(url_result.stdout.strip())) def is_dirty() -> bool: From 12f9ca67ce8a8b4ee799e7072683e0e2cbf3c977 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:26:35 +0000 Subject: [PATCH 18/25] Fix commit link URL to use /commits/ not /changes/ /pull//changes/ redirects to the standalone /commit/ view where comments are not PR-scoped. /pull//commits/ is the correct PR-scoped URL. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/address-pr-comments/commit-link.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/address-pr-comments/commit-link.py b/.claude/skills/address-pr-comments/commit-link.py index a26c38c30..9ec6fa12d 100755 --- a/.claude/skills/address-pr-comments/commit-link.py +++ b/.claude/skills/address-pr-comments/commit-link.py @@ -7,7 +7,7 @@ --pr auto-detected from the current branch via `gh pr view` --commit defaults to HEAD -The link points at the commit *within the PR* (/pull//changes/) +The link points at the commit *within the PR* (/pull//commits/) rather than the repo-wide commit view (/commit/), so replies posted against it are associated with the PR. Owner and repo are derived from the git remote automatically. @@ -74,7 +74,7 @@ def main() -> None: owner, repo = owner_repo_from_remote() pr = args.pr if args.pr is not None else detect_pr() commit = resolve_commit(args.commit) - link = f"https://github.com/{owner}/{repo}/pull/{pr}/changes/{commit}" + link = f"https://github.com/{owner}/{repo}/pull/{pr}/commits/{commit}" try: _ = args.reply_file.write_text(content.replace(PLACEHOLDER, link), encoding="utf-8") From 7fc2137a2f44d80ac51a6bbf8fc1f16db41c87b5 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:27:54 +0000 Subject: [PATCH 19/25] Render commit link as Markdown hyperlink in reply files Bare URLs get auto-linkified by GitHub which may trigger URL canonicalization; a Markdown link avoids that transformation. Uses short 7-char hash as display text. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/address-pr-comments/commit-link.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/skills/address-pr-comments/commit-link.py b/.claude/skills/address-pr-comments/commit-link.py index 9ec6fa12d..5df85da8a 100755 --- a/.claude/skills/address-pr-comments/commit-link.py +++ b/.claude/skills/address-pr-comments/commit-link.py @@ -74,7 +74,8 @@ def main() -> None: owner, repo = owner_repo_from_remote() pr = args.pr if args.pr is not None else detect_pr() commit = resolve_commit(args.commit) - link = f"https://github.com/{owner}/{repo}/pull/{pr}/commits/{commit}" + url = f"https://github.com/{owner}/{repo}/pull/{pr}/commits/{commit}" + link = f"[{commit[:7]}]({url})" try: _ = args.reply_file.write_text(content.replace(PLACEHOLDER, link), encoding="utf-8") From e8d45cf2f14e291ea0cf2d63e2b736965cddbf28 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:40:30 +0000 Subject: [PATCH 20/25] bump --- extensions/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/context.py b/extensions/context.py index 3f23b441c..ba05e80c3 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -91,7 +91,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["nuxt_test_utils_version"] = "^4.0.3" context["vue_eslint_parser_version"] = "^10.4.0" context["happy_dom_version"] = "^20.10.1" - context["node_kiota_bundle_version"] = "1.0.0-preview.102" + context["node_kiota_bundle_version"] = "1.0.0-preview.103" ####### # These are duplicated in the CI files for this repository context["default_node_version"] = "24.11.1" From ee8f45ac3011b7d79ef2b9905844fbac36e603f9 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:41:21 +0000 Subject: [PATCH 21/25] Update commit-link.py docstring to explain Markdown link rationale Documents that bare URLs are auto-canonicalised by GitHub to the standalone commit view, and why Markdown format is required to preserve the PR-scoped link. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/address-pr-comments/commit-link.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.claude/skills/address-pr-comments/commit-link.py b/.claude/skills/address-pr-comments/commit-link.py index 5df85da8a..8a566797a 100755 --- a/.claude/skills/address-pr-comments/commit-link.py +++ b/.claude/skills/address-pr-comments/commit-link.py @@ -7,10 +7,13 @@ --pr auto-detected from the current branch via `gh pr view` --commit defaults to HEAD -The link points at the commit *within the PR* (/pull//commits/) -rather than the repo-wide commit view (/commit/), so replies posted -against it are associated with the PR. Owner and repo are derived from the -git remote automatically. +The link is a Markdown hyperlink pointing at the commit *within the PR* +(/pull//commits/) rather than the repo-wide commit view +(/commit/), so replies posted against it are associated with the PR. +Markdown format is required because GitHub auto-canonicalises bare URLs in +comments, rewriting the PR-scoped path to the standalone /commit/ view +where review comments are not associated with the PR. Owner and repo are +derived from the git remote automatically. Prints "replaced" on success. Exits non-zero if the placeholder is absent. """ From 0f3ae6a8171ba6e5e83a08ad7b758fec713b4d54 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:42:16 +0000 Subject: [PATCH 22/25] Move Markdown link rationale from docstring to inline comment Implementation detail, not relevant at the module description level. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/address-pr-comments/commit-link.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.claude/skills/address-pr-comments/commit-link.py b/.claude/skills/address-pr-comments/commit-link.py index 8a566797a..a0ac79953 100755 --- a/.claude/skills/address-pr-comments/commit-link.py +++ b/.claude/skills/address-pr-comments/commit-link.py @@ -7,13 +7,10 @@ --pr auto-detected from the current branch via `gh pr view` --commit defaults to HEAD -The link is a Markdown hyperlink pointing at the commit *within the PR* -(/pull//commits/) rather than the repo-wide commit view -(/commit/), so replies posted against it are associated with the PR. -Markdown format is required because GitHub auto-canonicalises bare URLs in -comments, rewriting the PR-scoped path to the standalone /commit/ view -where review comments are not associated with the PR. Owner and repo are -derived from the git remote automatically. +The link points at the commit *within the PR* (/pull//commits/) +rather than the repo-wide commit view (/commit/), so replies posted +against it are associated with the PR. Owner and repo are derived from the +git remote automatically. Prints "replaced" on success. Exits non-zero if the placeholder is absent. """ @@ -78,6 +75,8 @@ def main() -> None: pr = args.pr if args.pr is not None else detect_pr() commit = resolve_commit(args.commit) url = f"https://github.com/{owner}/{repo}/pull/{pr}/commits/{commit}" + # Markdown format required: GitHub auto-canonicalises bare PR-scoped URLs to + # the standalone /commit/ view, where comments are not PR-associated. link = f"[{commit[:7]}]({url})" try: From aee79e43e508558ad1a84d59bcf49f32f7fee36c Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:45:25 +0000 Subject: [PATCH 23/25] Fix docstring: /commits/ redirects to PR-scoped changes view The URL we generate is not where the user lands; clarify that GitHub redirects it to the PR-scoped changes view. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/address-pr-comments/commit-link.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.claude/skills/address-pr-comments/commit-link.py b/.claude/skills/address-pr-comments/commit-link.py index a0ac79953..921ca12b2 100755 --- a/.claude/skills/address-pr-comments/commit-link.py +++ b/.claude/skills/address-pr-comments/commit-link.py @@ -7,10 +7,11 @@ --pr auto-detected from the current branch via `gh pr view` --commit defaults to HEAD -The link points at the commit *within the PR* (/pull//commits/) -rather than the repo-wide commit view (/commit/), so replies posted -against it are associated with the PR. Owner and repo are derived from the -git remote automatically. +The link points at the commit *within the PR* (GitHub redirects +/pull//commits/ to the PR-scoped changes view) rather than the +repo-wide commit view (/commit/), so replies posted against it are +associated with the PR. Owner and repo are derived from the git remote +automatically. Prints "replaced" on success. Exits non-zero if the placeholder is absent. """ From 865587bf0b4d0a98f31baff634f34ce645a3ec76 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:49:32 +0000 Subject: [PATCH 24/25] docstring --- .claude/skills/address-pr-comments/commit-link.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/address-pr-comments/commit-link.py b/.claude/skills/address-pr-comments/commit-link.py index 921ca12b2..16cbf9ad2 100755 --- a/.claude/skills/address-pr-comments/commit-link.py +++ b/.claude/skills/address-pr-comments/commit-link.py @@ -8,7 +8,7 @@ --commit defaults to HEAD The link points at the commit *within the PR* (GitHub redirects -/pull//commits/ to the PR-scoped changes view) rather than the +/pull//changes/ to the PR-scoped changes view) rather than the repo-wide commit view (/commit/), so replies posted against it are associated with the PR. Owner and repo are derived from the git remote automatically. @@ -75,7 +75,7 @@ def main() -> None: owner, repo = owner_repo_from_remote() pr = args.pr if args.pr is not None else detect_pr() commit = resolve_commit(args.commit) - url = f"https://github.com/{owner}/{repo}/pull/{pr}/commits/{commit}" + url = f"https://github.com/{owner}/{repo}/pull/{pr}/changes/{commit}" # Markdown format required: GitHub auto-canonicalises bare PR-scoped URLs to # the standalone /commit/ view, where comments are not PR-associated. link = f"[{commit[:7]}]({url})" From 491cb21bc9bc103b43478696b70ce859e9bbcf51 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 29 Jun 2026 16:52:52 +0000 Subject: [PATCH 25/25] liss --- .claude/skills/address-pr-comments/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/address-pr-comments/SKILL.md b/.claude/skills/address-pr-comments/SKILL.md index a8e53a745..e895fc2dc 100644 --- a/.claude/skills/address-pr-comments/SKILL.md +++ b/.claude/skills/address-pr-comments/SKILL.md @@ -35,7 +35,7 @@ Run the gate script once. Pass `--pr ` if the user supplied one; otherwi .claude/skills/address-pr-comments/verify-env.py [--pr ] ``` -It emits a JSON verdict: `repo_root`, `has_remote`, `branch`, `on_protected_branch`, `dirty`, and `pr` (`{number, state, title}` or `null`). This is the source for both the repo root (see [Conventions](#conventions)) and the PR — no separate `git rev-parse` or `gh pr view` calls are needed. +It emits a JSON verdict: `repo_root`, `has_remote`, `branch`, `on_protected_branch`, `dirty`, and `pr` (`{number, state, title}` or `null`). This is the source for both the repo root (see [Conventions](#conventions)) and the PR **STOP if:** - `has_remote` is `false` → "This skill requires a GitHub remote. Please add one with `git remote add origin ` first."